Skip to main content

grit_lib/
transfer.rs

1//! Embedder-facing transfer (fetch / push) result & option types, plus the
2//! negotiation-driven pack builder.
3//!
4//! This module is the foundation for the in-process fetch/push APIs that
5//! embedders such as `jj` and GitButler consume in place of `gix` transport.
6//! It defines the structured input/output types those APIs use and implements
7//! the single most important primitive — [`build_pack`] — which packs **only**
8//! the objects reachable from a negotiated set of `wants` and not already
9//! reachable from the remote's `haves`.
10//!
11//! Scope note (phase 1): only the local / `file://` object+ref copy path is in
12//! scope. `git://`, `http(s)`, and `ssh` transports plus credential-helper
13//! execution are out of scope and are left as TODOs in later phases.
14//!
15//! Push *result* reporting reuses [`crate::push_report::PushRefResult`] /
16//! [`crate::push_report::PushRefStatus`] rather than redefining it.
17
18use crate::environment::RepositoryOptions;
19use crate::partial_clone::ObjectFilterSpec;
20
21use std::collections::{HashMap, HashSet, VecDeque};
22use std::fs;
23use std::io::Write;
24use std::path::{Path, PathBuf};
25
26use crate::error::{Error, RemoteUnreadableReason, Result};
27use crate::fetch::{NoProgress, Progress};
28use crate::objects::{parse_commit, parse_tag, parse_tree, HashAlgo, ObjectId, ObjectKind};
29use crate::odb::Odb;
30use crate::progress::{ProgressPhase, ProgressSink, ReceivedObjects};
31use crate::push_report::{PushRefResult, PushRefStatus};
32use crate::refspec::{parse_fetch_refspec, RefspecItem};
33use std::num::NonZeroU32;
34
35/// How a single reference resolved during a fetch (or would resolve in a push).
36///
37/// Mirrors the shapes of `gix::remote::fetch::refs::update::Mode` that `jj`
38/// already consumes, so the embedder's translation layer stays a thin adapter.
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
41#[cfg_attr(feature = "serde-models", serde(rename_all = "snake_case"))]
42pub enum UpdateMode {
43    /// The local tracking ref did not exist and was created.
44    New,
45    /// The update advanced the ref along its existing history.
46    FastForward,
47    /// A non-fast-forward update that was applied because force was requested.
48    Forced,
49    /// The local ref already matched the remote value; nothing to do.
50    UpToDate,
51    /// No change was required (e.g. a no-op refspec).
52    NoChangeNeeded,
53    /// A non-fast-forward update that was rejected (force not requested).
54    NonFastForwardRejected,
55    /// A tag update was rejected (tags are not overwritten without force).
56    TagUpdateRejected,
57    /// The source object named by the refspec was not found on the remote.
58    SourceObjectNotFound,
59    /// The remote ref is unborn (points at nothing yet).
60    Unborn,
61    /// A prune/delete was requested but the local ref was already missing.
62    DeletedMissing,
63    /// The local tracking ref could not be created or updated (namespace conflict, lock, …).
64    RefLockRejected,
65}
66
67impl UpdateMode {
68    /// Returns whether fetch applied this update to the local repository.
69    ///
70    /// Rejected or no-op outcomes (for example an existing tag that moved on the
71    /// remote) are excluded so callers can report only refs that actually changed.
72    #[must_use]
73    pub fn modified_repo(self) -> bool {
74        matches!(
75            self,
76            Self::New | Self::FastForward | Self::Forced | Self::DeletedMissing
77        )
78    }
79
80    /// Returns whether fetch refused this update (non-fast-forward without force, a moved
81    /// tag, or a source missing on the remote).
82    #[must_use]
83    pub fn is_rejected(self) -> bool {
84        matches!(
85            self,
86            Self::NonFastForwardRejected
87                | Self::TagUpdateRejected
88                | Self::SourceObjectNotFound
89                | Self::RefLockRejected
90        )
91    }
92
93    /// Returns whether this update belongs in a fetch report: the ref changed
94    /// ([`Self::modified_repo`]) or the update was refused ([`Self::is_rejected`]).
95    ///
96    /// Up-to-date, no-op and unborn refs are left out.
97    #[must_use]
98    pub fn is_reported(self) -> bool {
99        self.modified_repo() || self.is_rejected()
100    }
101
102    /// Returns the coarse outcome a user sees for this update.
103    ///
104    /// Folds the detailed modes into one [`UpdateKind`]: every refusal becomes
105    /// [`UpdateKind::Rejected`], and the up-to-date, no-op and unborn modes become
106    /// [`UpdateKind::UpToDate`]. [`UpdateKind::changes_ref`] agrees with
107    /// [`Self::modified_repo`] and [`UpdateKind::Rejected`] with [`Self::is_rejected`].
108    #[must_use]
109    pub fn kind(self) -> UpdateKind {
110        match self {
111            Self::New => UpdateKind::New,
112            Self::FastForward => UpdateKind::FastForward,
113            Self::Forced => UpdateKind::Forced,
114            Self::DeletedMissing => UpdateKind::Pruned,
115            Self::NonFastForwardRejected
116            | Self::TagUpdateRejected
117            | Self::SourceObjectNotFound
118            | Self::RefLockRejected => UpdateKind::Rejected,
119            Self::UpToDate | Self::NoChangeNeeded | Self::Unborn => UpdateKind::UpToDate,
120        }
121    }
122
123    pub(crate) fn fetch_reflog_reason(self) -> Option<&'static str> {
124        match self {
125            Self::New => Some("storing head"),
126            Self::FastForward => Some("fast-forward"),
127            Self::Forced => Some("forced-update"),
128            _ => None,
129        }
130    }
131}
132
133/// What a fetch did to one local ref, as reported to a user ([`UpdateMode::kind`]).
134#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
135#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
136#[cfg_attr(feature = "serde-models", serde(rename_all = "snake_case"))]
137pub enum UpdateKind {
138    /// The local ref was created.
139    New,
140    /// The local ref advanced along its history.
141    FastForward,
142    /// The local ref was rewound or replaced because force was allowed.
143    Forced,
144    /// The local ref was deleted because the remote no longer has it.
145    Pruned,
146    /// The update was refused and the local ref left as it was.
147    Rejected,
148    /// The local ref already matched; nothing changed.
149    UpToDate,
150}
151
152impl UpdateKind {
153    /// Returns whether the local ref was (or, on a dry run, would be) changed.
154    #[must_use]
155    pub fn changes_ref(self) -> bool {
156        matches!(
157            self,
158            Self::New | Self::FastForward | Self::Forced | Self::Pruned
159        )
160    }
161}
162
163/// Identity and action for fetch ref-update reflog lines (non-initial fetches).
164pub(crate) struct FetchApplyReflog<'a> {
165    pub action: &'static str,
166    pub identity: &'a str,
167}
168
169/// The resolved outcome of one reference during a fetch.
170#[derive(Clone, Debug)]
171#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
172pub struct RefUpdate {
173    /// The remote-side ref name (e.g. `refs/heads/main`).
174    pub remote_ref: String,
175    /// The local-side ref name written, if any (e.g. `refs/remotes/origin/main`).
176    pub local_ref: Option<String>,
177    /// Previous value of the local ref (`None` when newly created).
178    pub old_oid: Option<ObjectId>,
179    /// New value written to the local ref (`None` for deletions / unborn).
180    pub new_oid: Option<ObjectId>,
181    /// How the update resolved.
182    pub mode: UpdateMode,
183    /// Optional human-readable note (reason text), for embedder display.
184    pub note: Option<String>,
185}
186
187/// Which tags to fetch alongside the requested refs.
188#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
189pub enum TagMode {
190    /// Do not fetch any tags automatically.
191    None,
192    /// Fetch tags that point into the history being fetched (like `git fetch`'s
193    /// default tag-following; not what `git clone` does).
194    ///
195    /// A tag is kept when the object it peels to arrives with this fetch, when
196    /// it is a commit already present locally and reachable from the fetched
197    /// refs, or when it is a tree or blob already present locally. Annotated
198    /// tags also need their tag object. Deepening or unshallowing follows tags
199    /// into the newly available history in the same fetch, without letting
200    /// automatic tags expand the requested branch depth.
201    ///
202    /// Git also keeps a tag on a local commit
203    /// that the fetched refs do not reach; grit does not. Deciding this reads
204    /// no objects for delivered targets, walks only commits for local ones, and
205    /// never lazy-fetches in a partial clone, so it stays cheap on large
206    /// histories.
207    ///
208    /// Tags the local repository already has at the advertised value are not
209    /// considered again, so they do not appear in [`FetchOutcome::updates`].
210    #[default]
211    Following,
212    /// Fetch every advertised `refs/tags/*` ref (same idea as `git clone` and
213    /// `git fetch --tags`).
214    All,
215}
216
217/// Shallow-history control for fetch and clone (`--depth`, `--deepen`, `--unshallow`).
218#[derive(Clone, Copy, Debug, PartialEq, Eq)]
219pub enum FetchDepth {
220    /// Truncate or establish history at the given absolute depth from each tip
221    /// (`git fetch --depth N`).
222    Depth(NonZeroU32),
223    /// Deepen an existing shallow repository by the given number of commits
224    /// (`git fetch --deepen N`, wire `deepen N` + `deepen-relative`).
225    Deepen(NonZeroU32),
226    /// Convert a shallow repository into a complete one (`git fetch --unshallow`).
227    Unshallow,
228}
229
230impl FetchDepth {
231    /// Build a [`FetchDepth::Depth`] when `n > 0`.
232    #[must_use]
233    pub fn depth(n: u32) -> Option<Self> {
234        NonZeroU32::new(n).map(Self::Depth)
235    }
236
237    /// Build a [`FetchDepth::Deepen`] when `n > 0`.
238    #[must_use]
239    pub fn deepen(n: u32) -> Option<Self> {
240        NonZeroU32::new(n).map(Self::Deepen)
241    }
242}
243
244/// Options controlling a fetch.
245#[non_exhaustive]
246pub struct FetchOptions {
247    /// Positive refspecs selecting what to fetch.
248    pub refspecs: Vec<String>,
249    /// Negative refspecs excluding refs from the positive set.
250    pub negative_refspecs: Vec<String>,
251    /// Tag-following policy.
252    pub tags: TagMode,
253    /// Whether to prune local tracking refs that vanished on the remote.
254    pub prune: bool,
255    /// Compute and report updates without writing any refs or objects.
256    pub dry_run: bool,
257    /// Shallow fetch control (`--depth`, `--deepen`, `--unshallow`). `None` leaves
258    /// history depth unchanged (aside from an existing on-disk `shallow` file).
259    pub fetch_depth: Option<FetchDepth>,
260    /// Deepen history to include commits no older than this cutoff
261    /// (`git fetch --shallow-since <date>`). The value is sent verbatim as the
262    /// wire `deepen-since <value>`; callers should pass the Unix timestamp Git's
263    /// `upload-pack` expects (a bare integer), not a human date string.
264    pub deepen_since: Option<String>,
265    /// Deepen history but stop at (exclude) commits reachable from these refs/oids
266    /// (`git fetch --shallow-exclude <ref>`). Each entry is sent as a wire
267    /// `deepen-not <ref>`.
268    pub deepen_not: Vec<String>,
269    /// First fetch populating a remote-tracking namespace (clone): write `packed-refs`
270    /// and `refs/remotes/{remote}/HEAD`.
271    pub(crate) initial_remote_fetch: bool,
272    /// Remote name paired with [`Self::initial_remote_fetch`] (e.g. `"origin"`).
273    pub remote_name: Option<String>,
274    /// Source label written into [`FETCH_HEAD`](https://git-scm.com/docs/git-fetch#_discussion).
275    /// [`crate::remote::Remote::fetch`] sets this from the fetch URL when unset.
276    pub fetch_source: Option<String>,
277    /// Configured `remote.<name>.fetch` refspecs (for opportunistic tracking when [`Self::refspecs`]
278    /// overrides them). [`crate::remote::Remote::fetch`] fills this for named remotes.
279    pub configured_refspecs: Vec<String>,
280    /// Clone-only reflog entries for `refs/remotes/{remote}/HEAD` (not remote branches).
281    pub clone_reflog: Option<CloneReflog>,
282    /// Reflog action prefix for non-initial fetches (`fetch`, `pull`, …).
283    pub(crate) reflog_action: &'static str,
284    /// Force ref updates that would otherwise be rejected (non-fast-forward branches,
285    /// tag clobber under [`TagMode::All`] or explicit refspecs). Does not move tags
286    /// that already exist locally under default tag following.
287    pub force: bool,
288    /// Diagnostic sink for [`crate::diagnostics::Trace::Network`] events.
289    pub diagnostics: Option<crate::diagnostics::DiagnosticsHandle>,
290    /// When true with [`Self::diagnostics`], fetch paths may emit network trace events.
291    pub network_trace: bool,
292    /// Override worker threads for index-pack after a received pack (`None` = config/default).
293    pub index_pack_threads: Option<usize>,
294    /// Global / embedder thread policy (`--threads`); applied when [`Self::index_pack_threads`] is unset.
295    pub thread_policy: crate::parallel::ThreadPolicy,
296    /// Discovery/config environment for pack ingest (`GIT_CONFIG_*`, `-c`); defaults to empty.
297    pub environment: crate::environment::Environment,
298    /// When true, spool the negotiated pack to disk and return without index-pack or ref updates.
299    pub(crate) pack_spool_only: bool,
300    /// Override the HTTP `Git-Protocol` header for this fetch (`None` → client default, usually v2).
301    pub http_protocol_version: Option<u8>,
302    /// When true, omit the `no-progress` capability/argument so the remote may
303    /// send side-band channel-2 progress. Default false preserves today's wire bytes.
304    pub remote_progress: bool,
305    /// Object filter for partial clone (`blob:none`, `blob:limit=<n>`). Omitted when
306    /// unset; promisor remotes may reapply `remote.*.partialclonefilter` automatically.
307    pub filter: Option<ObjectFilterSpec>,
308    /// Pick the filter from the promisor remotes the server advertises and the client
309    /// accepts, from their advertised `partialCloneFilter` (Git's `--filter=auto`; see
310    /// [`PromisorDecision::combined_filter`](crate::promisor_remote::PromisorDecision::combined_filter)).
311    /// Ignored when [`Self::filter`] is set. A remote whose stored filter is `auto` turns it on.
312    /// Without an accepted remote that advertises a usable filter, the fetch is unfiltered.
313    pub auto_filter: bool,
314    /// When true, the received pack is written with a sibling `.promisor` marker.
315    ///
316    /// Set by the fetch driver when a filter was sent on the wire; callers should
317    /// leave this `false`.
318    pub(crate) mark_received_pack_promisor: bool,
319    /// Explicit object ids to fetch (promisor on-demand); skips refspec matching and ref updates.
320    pub(crate) object_wants: Vec<ObjectId>,
321    /// On-demand promisor object fetch: always mark the received pack promisor.
322    pub(crate) promisor_object_fetch: bool,
323    /// Initial fetch from a plain local path: hard-link the source's packfiles and
324    /// loose objects wholesale instead of walking the requested refs (Git's
325    /// `clone --local`).
326    ///
327    /// The destination then also receives objects unreachable from the fetched
328    /// refs (other refs, excluded tags, unreachable leftovers). Only honored for
329    /// a local transport with no local history, no shallow request and a source
330    /// without alternates; otherwise the fetch sends exactly the objects
331    /// reachable from the requested refs. [`crate::remote::clone::clone`] sets it
332    /// for plain-path sources (not `file://` URLs). Defaults to `false`.
333    pub link_source_objects: bool,
334}
335
336impl Default for FetchOptions {
337    fn default() -> Self {
338        Self {
339            refspecs: Vec::new(),
340            negative_refspecs: Vec::new(),
341            tags: TagMode::Following,
342            prune: false,
343            dry_run: false,
344            fetch_depth: None,
345            deepen_since: None,
346            deepen_not: Vec::new(),
347            initial_remote_fetch: false,
348            remote_name: None,
349            fetch_source: None,
350            configured_refspecs: Vec::new(),
351            clone_reflog: None,
352            reflog_action: "fetch",
353            diagnostics: None,
354            network_trace: false,
355            index_pack_threads: None,
356            thread_policy: crate::parallel::ThreadPolicy::default(),
357            environment: crate::environment::Environment::empty(),
358            pack_spool_only: false,
359            http_protocol_version: None,
360            remote_progress: false,
361            filter: None,
362            auto_filter: false,
363            mark_received_pack_promisor: false,
364            object_wants: Vec::new(),
365            promisor_object_fetch: false,
366            link_source_objects: false,
367            force: false,
368        }
369    }
370}
371
372/// Identity and message for clone reflog entries written by the library.
373#[derive(Clone, Debug)]
374pub struct CloneReflog {
375    /// Committer identity for the clone reflog line.
376    pub identity: crate::ident::Signature,
377    /// Reflog message (e.g. `clone: from <url>`).
378    pub message: String,
379}
380
381impl Clone for FetchOptions {
382    fn clone(&self) -> Self {
383        Self {
384            refspecs: self.refspecs.clone(),
385            negative_refspecs: self.negative_refspecs.clone(),
386            tags: self.tags,
387            prune: self.prune,
388            dry_run: self.dry_run,
389            fetch_depth: self.fetch_depth,
390            deepen_since: self.deepen_since.clone(),
391            deepen_not: self.deepen_not.clone(),
392            initial_remote_fetch: self.initial_remote_fetch,
393            remote_name: self.remote_name.clone(),
394            fetch_source: self.fetch_source.clone(),
395            configured_refspecs: self.configured_refspecs.clone(),
396            clone_reflog: self.clone_reflog.clone(),
397            reflog_action: self.reflog_action,
398            diagnostics: self.diagnostics.clone(),
399            network_trace: self.network_trace,
400            index_pack_threads: self.index_pack_threads,
401            thread_policy: self.thread_policy,
402            environment: self.environment.clone(),
403            pack_spool_only: self.pack_spool_only,
404            http_protocol_version: self.http_protocol_version,
405            remote_progress: self.remote_progress,
406            filter: self.filter,
407            auto_filter: self.auto_filter,
408            mark_received_pack_promisor: self.mark_received_pack_promisor,
409            object_wants: self.object_wants.clone(),
410            promisor_object_fetch: self.promisor_object_fetch,
411            link_source_objects: self.link_source_objects,
412            force: self.force,
413        }
414    }
415}
416
417impl FetchOptions {
418    /// Default fetch options for callers that set fields individually.
419    #[must_use]
420    pub fn new() -> Self {
421        Self::default()
422    }
423
424    /// Clone layout fetch: populate `refs/remotes/{remote}/HEAD` and packed refs.
425    #[must_use]
426    pub fn initial_remote_fetch(mut self, enabled: bool) -> Self {
427        self.initial_remote_fetch = enabled;
428        self
429    }
430
431    /// Spool the received pack without index-pack (transport tests and diagnostics).
432    #[doc(hidden)]
433    #[must_use]
434    pub fn pack_spool_only(mut self, enabled: bool) -> Self {
435        self.pack_spool_only = enabled;
436        self
437    }
438
439    /// Promisor on-demand object fetch by oid (see [`crate::promisor_fetch`]).
440    #[doc(hidden)]
441    #[must_use]
442    pub fn promisor_object_wants(mut self, wants: Vec<ObjectId>) -> Self {
443        self.object_wants = wants;
444        self.promisor_object_fetch = true;
445        self
446    }
447
448    /// Whether this fetch carries any shallow/deepen request (an explicit
449    /// `fetch_depth`/`deepen-since`/`deepen-not`). Note this does NOT cover the
450    /// "already shallow, fetching more of the same boundary" case — that is
451    /// driven by the on-disk `shallow` file, checked separately by the fetch
452    /// paths via [`crate::shallow::load_shallow_oids`].
453    #[must_use]
454    pub fn has_deepen_request(&self) -> bool {
455        self.fetch_depth.is_some()
456            || self
457                .deepen_since
458                .as_deref()
459                .is_some_and(|v| !v.trim().is_empty())
460            || self.deepen_not.iter().any(|v| !v.trim().is_empty())
461    }
462
463    /// Wire `deepen N` value and whether `deepen-relative` must follow.
464    #[must_use]
465    pub(crate) fn wire_deepen(&self) -> Option<(u32, bool)> {
466        match self.fetch_depth {
467            Some(FetchDepth::Depth(n)) => Some((n.get(), false)),
468            Some(FetchDepth::Deepen(n)) => Some((n.get(), true)),
469            Some(FetchDepth::Unshallow) => Some((crate::shallow::INFINITE_DEPTH, false)),
470            None => None,
471        }
472    }
473
474    /// Absolute depth hint for tag-following on a shallow fetch (`--depth` only).
475    #[must_use]
476    pub(crate) fn absolute_depth_for_tags(&self) -> Option<u32> {
477        match self.fetch_depth {
478            Some(FetchDepth::Depth(n)) => Some(n.get()),
479            Some(FetchDepth::Deepen(_)) | Some(FetchDepth::Unshallow) | None => None,
480        }
481    }
482}
483
484/// Validate shallow fetch options against the local repository before negotiation.
485///
486/// # Errors
487///
488/// Returns [`Error::FetchUnshallowNotShallow`] when [`FetchDepth::Unshallow`] is
489/// requested but the repository has no shallow boundaries.
490pub fn validate_fetch_depth_local(local_git_dir: &Path, opts: &FetchOptions) -> Result<()> {
491    if matches!(opts.fetch_depth, Some(FetchDepth::Unshallow)) {
492        let local = crate::shallow::load_shallow_oids(local_git_dir)?;
493        if local.is_empty() {
494            return Err(Error::FetchUnshallowNotShallow);
495        }
496    }
497    Ok(())
498}
499
500/// Require server shallow/deepen capabilities for the requested fetch options.
501pub(crate) fn validate_fetch_depth_server(
502    opts: &FetchOptions,
503    v0_caps: Option<&str>,
504    v2_caps: Option<&[String]>,
505) -> Result<()> {
506    let needs_shallow = opts.has_deepen_request()
507        || opts.wire_deepen().is_some()
508        || opts
509            .deepen_since
510            .as_deref()
511            .is_some_and(|s| !s.trim().is_empty())
512        || opts.deepen_not.iter().any(|s| !s.trim().is_empty());
513    if !needs_shallow {
514        return Ok(());
515    }
516    if let Some(caps) = v0_caps {
517        if !caps.contains("shallow") {
518            return Err(Error::FetchShallowUnsupported);
519        }
520        if opts.wire_deepen().is_some_and(|(_, rel)| rel) && !caps.contains("deepen-relative") {
521            return Err(Error::FetchDeepenRelativeUnsupported);
522        }
523    } else if let Some(caps) = v2_caps {
524        if !crate::protocol_v2::fetch_supports_feature(caps, "shallow") {
525            return Err(Error::FetchShallowUnsupported);
526        }
527    }
528    Ok(())
529}
530
531/// Resolve whether a fetch should prune stale remote-tracking refs.
532///
533/// When `cli_prune` is [`Some`], that value wins (matching `--prune` /
534/// `--no-prune`). Otherwise reads `remote.<remote>.prune`, then `fetch.prune`,
535/// in Git precedence order. Unset keys default to `false`.
536///
537/// # Errors
538///
539/// Returns [`Error::Config`] when a configured prune key is not a valid boolean.
540pub fn resolve_fetch_prune(
541    config: &crate::config::ConfigSet,
542    remote: &str,
543    cli_prune: Option<bool>,
544) -> Result<bool> {
545    if let Some(enabled) = cli_prune {
546        return Ok(enabled);
547    }
548    let remote_key = format!("remote.{remote}.prune");
549    if let Some(value) = config.get_bool(&remote_key).transpose()? {
550        return Ok(value);
551    }
552    if let Some(value) = config.get_bool("fetch.prune").transpose()? {
553        return Ok(value);
554    }
555    Ok(false)
556}
557
558/// Resolve tag-following policy for a fetch.
559///
560/// When `cli_tags` is [`Some`], `true` maps to [`TagMode::All`] (`--tags`) and
561/// `false` to [`TagMode::None`] (`--no-tags`). Otherwise reads
562/// `remote.<remote>.tagOpt` (`--tags` / `--no-tags`, Git-compatible) and defaults
563/// to [`TagMode::Following`].
564#[must_use]
565pub fn resolve_fetch_tags(
566    config: &crate::config::ConfigSet,
567    remote: &str,
568    cli_tags: Option<bool>,
569) -> TagMode {
570    if let Some(all) = cli_tags {
571        return if all { TagMode::All } else { TagMode::None };
572    }
573    let key = format!("remote.{remote}.tagOpt");
574    match config.get(&key).as_deref().map(str::trim) {
575        Some("--tags") | Some("tags") | Some("all") => TagMode::All,
576        Some("--no-tags") | Some("no-tags") | Some("no") | Some("never") => TagMode::None,
577        _ => TagMode::Following,
578    }
579}
580
581/// Object and byte counts for objects received during a fetch or clone.
582#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
583#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
584pub struct TransferStats {
585    /// Objects installed from the received pack (zero when nothing was transferred).
586    pub objects: u64,
587    /// Size in bytes of the received pack file (zero when nothing was transferred).
588    pub bytes: u64,
589    /// Objects by type, when the pack was indexed locally (`None` when packs were
590    /// linked or copied from a local repository without being re-indexed).
591    pub kinds: Option<crate::index_pack::ObjectKindCounts>,
592}
593
594impl TransferStats {
595    /// Build stats from an ingested pack (installed under `objects/pack/` or written
596    /// into a custom primary store).
597    #[must_use]
598    pub fn from_ingested_pack(ingested: &crate::index_pack::IngestedPack) -> Self {
599        Self {
600            objects: u64::try_from(ingested.object_ids.len()).unwrap_or(u64::MAX),
601            bytes: ingested.pack_bytes,
602            kinds: Some(ingested.object_counts),
603        }
604    }
605}
606
607/// Totals across several transfers, such as every remote of a fetch-all.
608///
609/// Counts and bytes add up. [`TransferStats::kinds`] stays known only while every
610/// transfer that received objects reported its type breakdown.
611impl std::ops::AddAssign for TransferStats {
612    fn add_assign(&mut self, other: Self) {
613        self.kinds = match (self.kinds, other.kinds) {
614            (Some(mut mine), Some(theirs)) => {
615                mine += theirs;
616                Some(mine)
617            }
618            (mine, None) if other.objects == 0 => mine,
619            (None, theirs) if self.objects == 0 => theirs,
620            _ => None,
621        };
622        self.objects += other.objects;
623        self.bytes += other.bytes;
624    }
625}
626
627/// The structured result of a fetch, ready for the embedder's ref-store apply.
628#[derive(Clone, Debug, Default)]
629#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
630pub struct FetchOutcome {
631    /// Per-ref resolved updates.
632    pub updates: Vec<RefUpdate>,
633    /// The remote's default branch (from `HEAD` symref), if known.
634    pub default_branch: Option<String>,
635    /// New shallow boundary commits the server reported (`shallow <oid>`), already
636    /// applied to the local `shallow` file. The commits' parents are intentionally
637    /// absent from the local object store after this fetch.
638    pub new_shallow: Vec<ObjectId>,
639    /// Commits the server reported as no longer shallow (`unshallow <oid>`), i.e.
640    /// boundaries removed from the local `shallow` file because their history is
641    /// now complete. Populated by a deepen / `--unshallow` fetch.
642    pub new_unshallow: Vec<ObjectId>,
643    /// Objects and pack bytes ingested during this fetch.
644    pub transfer: TransferStats,
645    /// True when an object filter from [`FetchOptions::filter`] was sent on the wire
646    /// and the server advertised filter support. False with a requested filter means
647    /// the server does not support `filter` and sent every object; as in Git, the pack
648    /// is still marked promisor and the remote stays a promisor remote.
649    pub filter_sent: bool,
650    /// The `promisor-remote` exchange, when the server advertised promisor remotes
651    /// (protocol v2 only). Says which remotes were accepted and why others were not.
652    pub promisor: Option<crate::promisor_remote::PromisorHandshake>,
653}
654
655impl FetchOutcome {
656    /// Count of remote branch refs represented in [`Self::updates`].
657    #[must_use]
658    pub fn branch_count(&self) -> u64 {
659        self.updates
660            .iter()
661            .filter(|u| u.remote_ref.starts_with("refs/heads/"))
662            .count() as u64
663    }
664
665    /// Count of remote tag refs represented in [`Self::updates`] (peeled lines excluded).
666    #[must_use]
667    pub fn tag_count(&self) -> u64 {
668        self.updates
669            .iter()
670            .filter(|u| u.remote_ref.starts_with("refs/tags/") && !u.remote_ref.ends_with("^{}"))
671            .count() as u64
672    }
673
674    /// Updates worth reporting to a user, in fetch order ([`UpdateMode::is_reported`]).
675    pub fn reported_updates(&self) -> impl Iterator<Item = &RefUpdate> {
676        self.updates.iter().filter(|u| u.mode.is_reported())
677    }
678
679    /// Number of local refs this fetch changed ([`UpdateMode::modified_repo`]).
680    ///
681    /// For a [`FetchOptions::dry_run`] fetch this counts the refs that *would* change;
682    /// nothing was written.
683    #[must_use]
684    pub fn changed_ref_count(&self) -> usize {
685        self.updates
686            .iter()
687            .filter(|u| u.mode.modified_repo())
688            .count()
689    }
690
691    /// Whether any ref update was refused ([`UpdateMode::is_rejected`]).
692    #[must_use]
693    pub fn has_rejections(&self) -> bool {
694        self.updates.iter().any(|u| u.mode.is_rejected())
695    }
696}
697
698fn object_kind_counts_for_oids(
699    odb: &Odb,
700    oids: &HashSet<ObjectId>,
701) -> Result<crate::index_pack::ObjectKindCounts> {
702    use crate::index_pack::ObjectKindCounts;
703    let mut kinds = ObjectKindCounts::default();
704    for oid in oids {
705        kinds.add(odb.read_info(oid)?.kind);
706    }
707    Ok(kinds)
708}
709
710fn loose_transfer_stats(odb: &Odb, oids: &HashSet<ObjectId>) -> (u64, u64) {
711    let mut objects = 0u64;
712    let mut bytes = 0u64;
713    for size in oids.iter().filter_map(|oid| odb.local_loose_file_size(oid)) {
714        objects += 1;
715        bytes += size;
716    }
717    (objects, bytes)
718}
719
720fn pack_file_basenames(pack_dir: &Path) -> HashSet<String> {
721    let mut names = HashSet::new();
722    let Ok(entries) = fs::read_dir(pack_dir) else {
723        return names;
724    };
725    for entry in entries.flatten() {
726        let path = entry.path();
727        if path.extension().is_some_and(|e| e == "pack") {
728            if let Some(name) = path.file_name() {
729                names.insert(name.to_string_lossy().into_owned());
730            }
731        }
732    }
733    names
734}
735
736const LOCAL_LINK_KINDS_OID_PROBE_MAX: u64 = 4096;
737
738fn oids_from_newly_linked_pack_indexes(
739    local_odb: &Odb,
740    pack_dir_before: &HashSet<String>,
741) -> Result<Vec<ObjectId>> {
742    let pack_dir = local_odb.objects_dir().join("pack");
743    let mut oids = Vec::new();
744    if !pack_dir.is_dir() {
745        return Ok(oids);
746    }
747    for entry in fs::read_dir(&pack_dir).map_err(Error::Io)? {
748        let path = entry.map_err(Error::Io)?.path();
749        if path.extension().is_none_or(|e| e != "pack") {
750            continue;
751        }
752        let Some(name) = path.file_name() else {
753            continue;
754        };
755        if pack_dir_before.contains(name.to_string_lossy().as_ref()) {
756            continue;
757        }
758        let idx = path.with_extension("idx");
759        if !idx.is_file() {
760            continue;
761        }
762        let index = crate::pack::read_pack_index_no_verify(&idx)?;
763        for entry in index.iter() {
764            let oid = ObjectId::from_bytes(entry.oid())?;
765            oids.push(oid);
766        }
767    }
768    Ok(oids)
769}
770
771fn enrich_local_link_transfer_kinds(
772    local_odb: &Odb,
773    pack_dir_before: &HashSet<String>,
774    loose_reachable: &HashSet<ObjectId>,
775    transfer: &mut TransferStats,
776) -> Result<()> {
777    if transfer.kinds.is_some() || transfer.objects == 0 {
778        return Ok(());
779    }
780    if transfer.objects > LOCAL_LINK_KINDS_OID_PROBE_MAX {
781        return Ok(());
782    }
783    let mut probe: HashSet<ObjectId> = loose_reachable.clone();
784    for oid in oids_from_newly_linked_pack_indexes(local_odb, pack_dir_before)? {
785        probe.insert(oid);
786    }
787    if u64::try_from(probe.len()).unwrap_or(u64::MAX) != transfer.objects {
788        return Ok(());
789    }
790    transfer.kinds = Some(object_kind_counts_for_oids(local_odb, &probe)?);
791    Ok(())
792}
793
794/// Per-fetch transfer stats for objects installed during one local copy operation.
795///
796/// `loose_candidates` limits which installed objects are looked for as loose files (each
797/// costs a `stat`); `None` checks all of `installed`.
798fn transfer_stats_for_local_operation(
799    local_odb: &Odb,
800    installed: &HashSet<ObjectId>,
801    loose_candidates: Option<&HashSet<ObjectId>>,
802    pack_dir_before: &HashSet<String>,
803) -> Result<TransferStats> {
804    let objects_dir = local_odb.objects_dir();
805    let pack_dir = objects_dir.join("pack");
806    let mut bytes = 0u64;
807    let mut objects_from_new_packs = 0u64;
808    if pack_dir.is_dir() {
809        for entry in fs::read_dir(&pack_dir).map_err(Error::Io)? {
810            let path = entry.map_err(Error::Io)?.path();
811            if path.extension().is_none_or(|e| e != "pack") {
812                continue;
813            }
814            let Some(name) = path.file_name() else {
815                continue;
816            };
817            let name = name.to_string_lossy();
818            if pack_dir_before.contains(name.as_ref()) {
819                continue;
820            }
821            bytes += fs::metadata(&path).map_err(Error::Io)?.len();
822            let idx = path.with_extension("idx");
823            if idx.is_file() {
824                let index = crate::pack::read_pack_index_no_verify(&idx)?;
825                objects_from_new_packs += u64::try_from(index.len()).unwrap_or(u64::MAX);
826            }
827        }
828    }
829    let (loose_objects, loose_bytes) =
830        loose_transfer_stats(local_odb, loose_candidates.unwrap_or(installed));
831    bytes = bytes.saturating_add(loose_bytes);
832    let installed_count = u64::try_from(installed.len()).unwrap_or(u64::MAX);
833    let objects = if objects_from_new_packs > 0 {
834        objects_from_new_packs.saturating_add(loose_objects)
835    } else {
836        installed_count
837    };
838    let kinds = if objects == installed_count && installed_count <= LOCAL_LINK_KINDS_OID_PROBE_MAX {
839        Some(object_kind_counts_for_oids(local_odb, installed)?)
840    } else {
841        None
842    };
843    Ok(TransferStats {
844        objects,
845        bytes,
846        kinds,
847    })
848}
849
850/// A single ref update requested by a push.
851#[derive(Clone, Debug, Default)]
852pub struct PushRefSpec {
853    /// The source object to push (`None` for a deletion).
854    pub src: Option<ObjectId>,
855    /// Local source ref name when known (for reports and `git push --porcelain`).
856    pub src_ref: Option<String>,
857    /// The destination ref on the remote (e.g. `refs/heads/main`).
858    pub dst: String,
859    /// Whether a non-fast-forward update is allowed.
860    pub force: bool,
861    /// Whether this update deletes the remote ref.
862    pub delete: bool,
863    /// Compare-and-swap expectation: the remote ref's current value must match
864    /// this (force-with-lease). `None` disables the value check.
865    pub expected_old: Option<ObjectId>,
866    /// Force-with-lease expectation that the remote ref does **not** currently
867    /// exist. When `true`, a push whose destination already exists on the remote
868    /// is rejected as stale (used for "create only" pushes whose lease is the
869    /// ref's absence). Independent of [`Self::expected_old`].
870    pub expect_absent: bool,
871    /// Local ref as the `pre-push` hook names it, when that differs from
872    /// [`Self::src_ref`]: `HEAD` for a `HEAD:<dst>` push, where `src_ref` holds the
873    /// branch `HEAD` points at. `None` uses `src_ref`.
874    pub hook_src_ref: Option<String>,
875}
876
877/// Local ref name carried on a push spec for status reporting.
878#[must_use]
879pub(crate) fn push_spec_local_ref(spec: &PushRefSpec) -> Option<String> {
880    spec.src_ref.clone()
881}
882
883/// Resolve the remote ref name for a push deletion from a short name or full ref.
884///
885/// When `spec` is a short name, a local `refs/heads/<spec>` wins, then an
886/// advertised remote branch, then an advertised remote tag, otherwise
887/// `refs/heads/<spec>` (same default as `git push <remote> :<branch>`).
888#[must_use]
889pub fn resolve_push_delete_ref(
890    git_dir: &Path,
891    remote_ref_names: &HashSet<String>,
892    spec: &str,
893) -> String {
894    if spec.starts_with("refs/") {
895        return spec.to_owned();
896    }
897    let branch = format!("refs/heads/{spec}");
898    let tag = format!("refs/tags/{spec}");
899    if crate::refs::resolve_ref(git_dir, &branch).is_ok() {
900        return branch;
901    }
902    if remote_ref_names.contains(&branch) {
903        return branch;
904    }
905    if crate::refs::resolve_ref(git_dir, &tag).is_ok() {
906        return tag;
907    }
908    if remote_ref_names.contains(&tag) {
909        return tag;
910    }
911    branch
912}
913
914/// Options controlling a push.
915#[derive(Clone, Default)]
916pub struct PushOptions {
917    /// Apply all updates atomically (all-or-nothing), including remote hook rejections.
918    ///
919    /// Wire pushes require the server's `atomic` capability. If it is absent,
920    /// returns [`crate::error::Error::PushAtomicUnsupported`] before sending any
921    /// ref-update commands or objects, including with [`Self::dry_run`]. There
922    /// is no fallback to a non-atomic push.
923    pub atomic: bool,
924    /// Compute results without writing to the remote.
925    pub dry_run: bool,
926    /// When set, update `refs/remotes/<name>/...` in the local repo after a
927    /// successful push (matching Git's post-push tracking ref maintenance).
928    pub tracking_remote: Option<String>,
929    /// Server-side push options to transmit (`git push --push-option <value>`).
930    ///
931    /// When non-empty, the negotiated capability list includes `push-options`
932    /// and one `push-option <value>` pkt-line per entry is written after the
933    /// ref-update command block and before the flush/pack. The remote exposes
934    /// these to its hooks via `GIT_PUSH_OPTION_COUNT` / `GIT_PUSH_OPTION_<n>`.
935    ///
936    /// If this is non-empty but the remote `git-receive-pack` does not advertise
937    /// the `push-options` capability, the push fails with
938    /// [`crate::error::Error::PushOptionsUnsupported`] (matching Git). A local-path or
939    /// `file://` push ([`push_local`]) accepts them only when the destination repository
940    /// sets `receive.advertisePushOptions`, and passes them to its `pre-receive` and
941    /// `post-receive` hooks. Either check runs before anything is written, dry runs included.
942    pub push_options: Vec<String>,
943    /// Diagnostic sink for [`crate::diagnostics::Trace::Network`] events.
944    pub diagnostics: Option<crate::diagnostics::DiagnosticsHandle>,
945    /// When true with [`Self::diagnostics`], push paths may emit network trace events.
946    pub network_trace: bool,
947    /// Ref tips already read from the remote, for example while planning
948    /// ([`PushPlanContext::remote_tips`](crate::remote::push_plan::PushPlanContext)).
949    ///
950    /// A local push uses them as the objects the remote already has instead of listing the
951    /// remote's refs again; ref updates still check each destination's current value. They
952    /// must come from the push destination, or objects the destination lacks may be left out
953    /// of the pack. Wire pushes read the advertisement from their connection and ignore this.
954    pub remote_ref_tips: Option<Vec<ObjectId>>,
955    /// Packs larger than this many bytes go to the server's resumable
956    /// `pushBundle` endpoint, when it advertises one, instead of the
957    /// receive-pack request. `None` uses `push.bundleThreshold` from config,
958    /// else 16 MiB.
959    pub resumable_threshold: Option<u64>,
960    /// Skip the client-side `pre-push` hook.
961    ///
962    /// [`crate::remote::Remote::push`] runs the hook (see
963    /// [`crate::hooks::run_pre_push_hook`]) after reading the remote's refs and before sending
964    /// anything, dry runs included, and fails with [`Error::Hook`] when it exits non-zero. The
965    /// lower-level [`push_local`] and [`crate::push::push_remote`] have no repository or
966    /// remote name to give the hook and never run it.
967    pub no_verify: bool,
968}
969
970/// The client-side `pre-push` hook for one push, run once every update has been decided.
971pub(crate) struct PrePushGate<'a> {
972    /// Repository whose hooks run.
973    pub(crate) repo: &'a crate::repo::Repository,
974    /// First hook argument: remote name, or the URL for an unnamed remote.
975    pub(crate) remote: &'a str,
976    /// Second hook argument: the push URL.
977    pub(crate) url: &'a str,
978}
979
980impl PrePushGate<'_> {
981    /// Run the hook over the updates the push would make; refs that are up to date or
982    /// already rejected locally are left out, as Git does.
983    ///
984    /// # Errors
985    ///
986    /// [`Error::Hook`] when the hook fails or cannot run.
987    pub(crate) fn check<'r>(
988        &self,
989        updates: impl IntoIterator<Item = (&'r PushRefSpec, &'r PushRefResult)>,
990    ) -> Result<()> {
991        let refs: Vec<crate::hooks::PrePushRef> = updates
992            .into_iter()
993            .filter(|(_, r)| {
994                !matches!(
995                    r.status,
996                    PushRefStatus::UpToDate
997                        | PushRefStatus::RejectNonFastForward
998                        | PushRefStatus::RejectNeedsForce
999                        | PushRefStatus::RejectStale
1000                        | PushRefStatus::RejectRemoteRefMissing
1001                )
1002            })
1003            .map(|(spec, r)| crate::hooks::PrePushRef {
1004                local_ref: if r.deletion {
1005                    None
1006                } else {
1007                    spec.hook_src_ref.clone().or_else(|| r.local_ref.clone())
1008                },
1009                local_oid: r.new_oid,
1010                remote_ref: r.remote_ref.clone(),
1011                remote_oid: r.old_oid,
1012            })
1013            .collect();
1014        crate::hooks::run_pre_push_hook(self.repo, self.remote, self.url, &refs)?;
1015        Ok(())
1016    }
1017}
1018
1019/// The structured result of a push. Reuses [`PushRefResult`] for per-ref status.
1020#[derive(Clone, Debug, Default)]
1021#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
1022pub struct PushOutcome {
1023    /// Per-ref resolved results (status, old/new oid, reason).
1024    pub results: Vec<PushRefResult>,
1025    /// Hook and diagnostic lines from the remote (side-band channel 2), in order.
1026    ///
1027    /// For a local-path or `file://` push, the output of the destination's receive hooks.
1028    /// Empty when the server did not use side-band or no hook wrote anything.
1029    pub remote_messages: Vec<String>,
1030}
1031
1032impl PushOutcome {
1033    /// Whether any remote ref was updated, or on a dry run would be
1034    /// ([`PushRefStatus::Ok`]).
1035    #[must_use]
1036    pub fn has_updates(&self) -> bool {
1037        self.results.iter().any(|r| r.status == PushRefStatus::Ok)
1038    }
1039
1040    /// Whether any ref update was refused, locally or by the remote
1041    /// ([`PushRefStatus::is_error`]).
1042    #[must_use]
1043    pub fn has_rejections(&self) -> bool {
1044        self.results.iter().any(|r| r.status.is_error())
1045    }
1046}
1047
1048pub use crate::pack_objects::{
1049    build_pack, build_pack_from_send_list, reachable_closure, PackBuildOptions, PackObjects,
1050    PackObjectsOptions, PackStats,
1051};
1052
1053pub(crate) use crate::pack_objects::write_pack_for_local_fetch;
1054
1055fn pack_build_options_for_send(
1056    source_odb: &Odb,
1057    oids: &[ObjectId],
1058    base: &PackBuildOptions,
1059) -> PackBuildOptions {
1060    if oids.is_empty() || oids.len() > 512 {
1061        return *base;
1062    }
1063    let commits_tags_only = oids.iter().all(|oid| {
1064        source_odb
1065            .read(oid)
1066            .map(|obj| matches!(obj.kind, ObjectKind::Commit | ObjectKind::Tag))
1067            .unwrap_or(false)
1068    });
1069    if !commits_tags_only {
1070        return *base;
1071    }
1072    let mut fast = *base;
1073    fast.delta = false;
1074    fast.reuse_deltas = false;
1075    fast.thin = false;
1076    fast
1077}
1078
1079fn ingest_pack_options_for_fetch(
1080    local_git_dir: &Path,
1081    fetch_opts: &FetchOptions,
1082) -> Result<crate::index_pack::IngestPackOptions> {
1083    let pack_config =
1084        crate::config::ConfigSet::load(&fetch_opts.environment, Some(local_git_dir), true)?;
1085    let index_threads = crate::parallel::index_pack_threads_for_ingest(
1086        fetch_opts.thread_policy,
1087        &pack_config,
1088        fetch_opts.index_pack_threads,
1089    )?;
1090    Ok(crate::index_pack::IngestPackOptions {
1091        fix_thin: true,
1092        skip_post_index_verify: true,
1093        threads: index_threads,
1094        merged_config: Some(pack_config),
1095        environment: fetch_opts.environment.clone(),
1096        mark_promisor_pack: fetch_opts.mark_received_pack_promisor,
1097        keep_message: None,
1098        trusted_index_rows: None,
1099    })
1100}
1101
1102/// Incremental [`ProgressPhase::CopyingObjects`] reporting for local fetch/push copies.
1103struct LocalCopyProgress {
1104    total: u64,
1105    done: u64,
1106    last_reported: u64,
1107    phase_active: bool,
1108}
1109
1110/// Forwards loose-object link [`ProgressSink::inc`] into [`LocalCopyProgress::add`].
1111struct CopyingLooseLink<'a> {
1112    copy: &'a mut LocalCopyProgress,
1113    progress: &'a mut dyn Progress,
1114}
1115
1116impl ProgressSink for CopyingLooseLink<'_> {
1117    fn inc(&mut self, n: u64) {
1118        self.copy.add(self.progress, n);
1119    }
1120}
1121
1122impl LocalCopyProgress {
1123    fn new(total: u64) -> Self {
1124        Self {
1125            total,
1126            done: 0,
1127            last_reported: 0,
1128            phase_active: false,
1129        }
1130    }
1131
1132    fn ensure_started(&mut self, progress: &mut dyn Progress) {
1133        if !self.phase_active {
1134            progress.start(ProgressPhase::CopyingObjects, Some(self.total));
1135            self.phase_active = true;
1136        }
1137    }
1138
1139    fn add(&mut self, progress: &mut dyn Progress, n: u64) {
1140        if n == 0 {
1141            return;
1142        }
1143        self.ensure_started(progress);
1144        self.done = self.done.saturating_add(n).min(self.total);
1145        if self.last_reported < self.done {
1146            crate::progress::report_object_progress_stride(
1147                progress,
1148                self.done,
1149                &mut self.last_reported,
1150                Some(self.total),
1151            );
1152        }
1153        if progress.wants_received_objects() {
1154            progress.received_objects(ReceivedObjects {
1155                received: self.done,
1156                total: self.total,
1157                deltas: 0,
1158            });
1159        }
1160    }
1161
1162    fn finish(&mut self, progress: &mut dyn Progress) {
1163        if !self.phase_active {
1164            return;
1165        }
1166        if self.last_reported < self.done {
1167            progress.set(self.done);
1168            self.last_reported = self.done;
1169        }
1170        progress.finish();
1171        self.phase_active = false;
1172    }
1173}
1174
1175/// Copy objects for a local fetch: link remote packfiles that hold only objects
1176/// the fetch must send, then link or pack any remainder.
1177///
1178/// [`FetchOptions::link_source_objects`] instead links the whole source store up
1179/// front, unreachable objects included (a plain-path clone).
1180#[allow(clippy::too_many_arguments)]
1181fn copy_objects_for_local_fetch(
1182    local_git_dir: &Path,
1183    remote_odb: &Odb,
1184    local_odb: &Odb,
1185    wants: &[ObjectId],
1186    haves: &[ObjectId],
1187    source_shallow: &HashSet<ObjectId>,
1188    have_shallow: &HashSet<ObjectId>,
1189    try_pack_link: bool,
1190    pack_build: &PackBuildOptions,
1191    fetch_opts: &FetchOptions,
1192    progress: &mut dyn Progress,
1193    cancel: &dyn crate::progress::Cancel,
1194    prep_enumerating: bool,
1195) -> Result<(HashSet<ObjectId>, TransferStats)> {
1196    use crate::local_object_copy::{
1197        count_store_objects_for_link, link_loose_objects, link_loose_objects_for_oids,
1198        link_packs_within, link_remote_pack_files, linked_object_set, refresh_local_odb_after_link,
1199    };
1200
1201    let remote_objects = remote_odb.objects_dir();
1202    let local_objects = local_odb.objects_dir();
1203    let pack_dir_before = pack_file_basenames(&local_objects.join("pack"));
1204    let remote_has_alternates = !crate::pack::read_alternates_recursive(remote_objects)?.is_empty();
1205
1206    if local_odb.uses_files_primary()
1207        && fetch_opts.link_source_objects
1208        && !remote_has_alternates
1209        && !pack_build.thin
1210        && try_pack_link
1211        && haves.is_empty()
1212        && !wants.is_empty()
1213    {
1214        crate::progress::poll_cancel(progress, cancel)?;
1215        let copy_total = count_store_objects_for_link(remote_objects)? as u64;
1216        let mut copy = LocalCopyProgress::new(copy_total);
1217        let linked_packs = link_remote_pack_files(
1218            remote_objects,
1219            local_objects,
1220            &mut crate::progress::NullProgress,
1221            cancel,
1222            0,
1223        )?;
1224        if linked_packs.objects > 0 {
1225            refresh_local_odb_after_link(local_odb);
1226            copy.add(progress, linked_packs.objects as u64);
1227        }
1228        let pack_alone_satisfies_wants =
1229            linked_packs.objects > 0 && wants.iter().all(|oid| local_odb.exists(oid));
1230        let linked_loose = link_loose_objects(
1231            remote_objects,
1232            local_objects,
1233            &mut crate::progress::NullProgress,
1234            cancel,
1235        )?;
1236        if linked_loose > 0 {
1237            refresh_local_odb_after_link(local_odb);
1238            copy.add(progress, linked_loose as u64);
1239        }
1240        if (linked_packs.files > 0 || linked_loose > 0)
1241            && wants.iter().all(|oid| local_odb.exists(oid))
1242        {
1243            copy.finish(progress);
1244            let stats_loose = if pack_alone_satisfies_wants {
1245                HashSet::new()
1246            } else {
1247                crate::local_object_copy::loose_reachable_from_roots(
1248                    local_odb,
1249                    wants,
1250                    source_shallow,
1251                    cancel,
1252                )?
1253            };
1254            let oids = if stats_loose.is_empty() {
1255                linked_object_set(local_odb, wants)
1256            } else {
1257                stats_loose.clone()
1258            };
1259            let mut transfer = transfer_stats_for_local_operation(
1260                local_odb,
1261                &stats_loose,
1262                None,
1263                &pack_dir_before,
1264            )?;
1265            enrich_local_link_transfer_kinds(
1266                local_odb,
1267                &pack_dir_before,
1268                &stats_loose,
1269                &mut transfer,
1270            )?;
1271            return Ok((oids, transfer));
1272        }
1273        copy.finish(progress);
1274    }
1275
1276    let stop = crate::progress::CancelSignal::new(cancel);
1277    let have_tips: HashSet<ObjectId> = haves.iter().copied().collect();
1278    if !prep_enumerating {
1279        progress.start(ProgressPhase::EnumeratingObjects, None);
1280    }
1281    let mut enum_last = 0u64;
1282    let send_set = collect_reachable_excluding_peer(
1283        remote_odb,
1284        wants,
1285        local_odb,
1286        &have_tips,
1287        false,
1288        source_shallow,
1289        &stop,
1290        progress,
1291        &mut enum_last,
1292    )?;
1293    let send = &send_set.objects;
1294    let send_len = send.len() as u64;
1295    progress.set(send_len);
1296
1297    let will_scan_packs =
1298        local_odb.uses_files_primary() && !pack_build.thin && try_pack_link && !send.is_empty();
1299    if !will_scan_packs {
1300        progress.finish();
1301    }
1302
1303    let mut copy = LocalCopyProgress::new(send_len);
1304
1305    if will_scan_packs {
1306        let local_has = |oid: &ObjectId| local_odb.exists_materialized_local_without_rescan(oid);
1307        let mut pack_scan = send_len;
1308        let mut pack_scan_last = send_len;
1309        let mut on_pack_scan = |n: u64| {
1310            pack_scan = pack_scan.saturating_add(n);
1311            crate::progress::report_object_progress_stride(
1312                progress,
1313                pack_scan,
1314                &mut pack_scan_last,
1315                None,
1316            );
1317        };
1318        let linked = link_packs_within(
1319            remote_objects,
1320            local_objects,
1321            send,
1322            &local_has,
1323            &mut crate::progress::NullProgress,
1324            cancel,
1325            Some(&mut on_pack_scan),
1326        )?;
1327        progress.finish();
1328        if linked > 0 {
1329            refresh_local_odb_after_link(local_odb);
1330            copy.add(progress, linked as u64);
1331        }
1332    }
1333
1334    let mut still_missing = objects_missing_from_peer(local_odb, send)?;
1335    if still_missing.is_empty() {
1336        copy.finish(progress);
1337        // Every object in `send` is stored locally now, and only packs were installed.
1338        let oids: HashSet<ObjectId> = send.iter().copied().collect();
1339        let transfer = transfer_stats_for_local_operation(
1340            local_odb,
1341            &oids,
1342            Some(&HashSet::new()),
1343            &pack_dir_before,
1344        )?;
1345        return Ok((oids, transfer));
1346    }
1347
1348    // Hard-link loose objects before building an in-memory pack. Re-reading and
1349    // recompressing large loose blobs (typical local clone) would spike RSS to several
1350    // times the object size; linking matches system `git clone` on the same filesystem.
1351    let loose_candidates: HashSet<ObjectId> = still_missing.iter().copied().collect();
1352    if local_odb.uses_files_primary() {
1353        let mut loose_link = CopyingLooseLink {
1354            copy: &mut copy,
1355            progress,
1356        };
1357        let linked_loose = link_loose_objects_for_oids(
1358            remote_objects,
1359            local_objects,
1360            &still_missing,
1361            &mut loose_link,
1362            cancel,
1363        )?;
1364        if linked_loose > 0 {
1365            refresh_local_odb_after_link(local_odb);
1366            copy.add(progress, linked_loose as u64);
1367            still_missing = local_odb.missing_oids(still_missing)?;
1368        }
1369    }
1370    if still_missing.is_empty() {
1371        copy.finish(progress);
1372        // Every object in `send` is stored locally now; the loose links covered the rest.
1373        let oids: HashSet<ObjectId> = send.iter().copied().collect();
1374        let transfer = transfer_stats_for_local_operation(
1375            local_odb,
1376            &oids,
1377            Some(&loose_candidates),
1378            &pack_dir_before,
1379        )?;
1380        return Ok((oids, transfer));
1381    }
1382
1383    let pack_opts = pack_build_options_for_send(remote_odb, &still_missing, pack_build);
1384    crate::progress::poll_cancel(progress, cancel)?;
1385    // Stream the pack into a spool in `objects/pack` rather than memory, as a local push
1386    // does; a cancelled or failed copy removes the spool.
1387    let mut spool = crate::pack_receive::TempPackReceive::create_in_pack_dir(local_objects)?;
1388    write_pack_for_local_transfer(
1389        remote_odb,
1390        &send_set,
1391        &still_missing,
1392        local_odb,
1393        haves,
1394        source_shallow,
1395        have_shallow,
1396        &pack_opts,
1397        &stop,
1398        &mut spool,
1399        Some((&mut copy, progress)),
1400    )?;
1401    copy.finish(progress);
1402    crate::progress::poll_cancel(progress, cancel)?;
1403    let ingested = match spool.finish()? {
1404        Some(pack_path) => {
1405            let ingest_opts = ingest_pack_options_for_fetch(local_git_dir, fetch_opts)?;
1406            crate::index_pack::ingest_received_pack_path_with_progress(
1407                pack_path,
1408                local_odb,
1409                &ingest_opts,
1410                Some(progress),
1411                Some(cancel),
1412            )?
1413        }
1414        None => crate::index_pack::IngestedPack::default(),
1415    };
1416    refresh_local_odb_after_link(local_odb);
1417    // Every object in `send` is stored locally now: linked, or in the new pack.
1418    let mut out: HashSet<ObjectId> = send.iter().copied().collect();
1419    out.extend(ingested.object_ids);
1420    let transfer = transfer_stats_for_local_operation(local_odb, &out, None, &pack_dir_before)?;
1421    Ok((out, transfer))
1422}
1423
1424/// Copy objects for a local push: link source packfiles that hold only objects
1425/// the push must send into `dest_odb`, then pack any remainder.
1426///
1427/// `peer_odb` is the remote's own object store: the walk stops at objects it already
1428/// has, and it answers for `haves`. `dest_odb` receives the copied objects (the
1429/// remote's receive quarantine) and must read `peer_odb` as an alternate.
1430///
1431/// The remainder is streamed into a `tmp_pack_*` spool in `dest_odb`'s `pack/`
1432/// directory and indexed from there, so the pack is never held in memory. `cancel`
1433/// is polled throughout the walk, the linking, the pack build and the indexing; a
1434/// cancelled copy removes its spool and staging files and publishes no pack.
1435#[allow(clippy::too_many_arguments)]
1436fn copy_objects_for_local_push(
1437    local_odb: &Odb,
1438    peer_odb: &Odb,
1439    dest_odb: &Odb,
1440    wants: &[ObjectId],
1441    haves: &[ObjectId],
1442    source_shallow: &HashSet<ObjectId>,
1443    have_shallow: &HashSet<ObjectId>,
1444    opts: &PackBuildOptions,
1445    cancel: &dyn crate::progress::Cancel,
1446) -> Result<()> {
1447    use crate::local_object_copy::{
1448        link_loose_objects_for_oids, link_packs_within, refresh_local_odb_after_link,
1449    };
1450
1451    let local_objects = local_odb.objects_dir();
1452    let dest_objects = dest_odb.objects_dir();
1453    let stop = crate::progress::CancelSignal::new(cancel);
1454
1455    let have_tips: HashSet<ObjectId> = haves.iter().copied().collect();
1456    let mut enum_last = 0u64;
1457    let mut enum_sink = crate::progress::NullProgress;
1458    let send_set = collect_reachable_excluding_peer(
1459        local_odb,
1460        wants,
1461        peer_odb,
1462        &have_tips,
1463        false,
1464        source_shallow,
1465        &stop,
1466        &mut enum_sink,
1467        &mut enum_last,
1468    )?;
1469    let send = &send_set.objects;
1470
1471    let mut progress = crate::progress::NullProgress;
1472    let dest_has = |oid: &ObjectId| dest_odb.exists_materialized_local_without_rescan(oid);
1473    if local_odb.uses_files_primary()
1474        && !opts.thin
1475        && link_packs_within(
1476            local_objects,
1477            dest_objects,
1478            send,
1479            &dest_has,
1480            &mut progress,
1481            cancel,
1482            None,
1483        )? > 0
1484    {
1485        refresh_local_odb_after_link(dest_odb);
1486    }
1487
1488    let mut still_missing = objects_missing_from_peer(dest_odb, send)?;
1489    if still_missing.is_empty() {
1490        return Ok(());
1491    }
1492
1493    if local_odb.uses_files_primary() && !haves.is_empty() {
1494        let linked_loose = link_loose_objects_for_oids(
1495            local_objects,
1496            dest_objects,
1497            &still_missing,
1498            &mut progress,
1499            cancel,
1500        )?;
1501        if linked_loose > 0 {
1502            refresh_local_odb_after_link(dest_odb);
1503            still_missing = dest_odb.missing_oids(still_missing)?;
1504        }
1505        if still_missing.is_empty() {
1506            return Ok(());
1507        }
1508    }
1509
1510    let pack_opts = pack_build_options_for_send(local_odb, &still_missing, opts);
1511    let mut spool = crate::pack_receive::TempPackReceive::create_in_pack_dir(dest_objects)?;
1512    write_pack_for_local_transfer(
1513        local_odb,
1514        &send_set,
1515        &still_missing,
1516        peer_odb,
1517        haves,
1518        source_shallow,
1519        have_shallow,
1520        &pack_opts,
1521        &stop,
1522        &mut spool,
1523        None,
1524    )?;
1525    let Some(pack_path) = spool.finish()? else {
1526        return Ok(());
1527    };
1528    // Indexing hashes every object and checks the trailer, as receive-pack does, so
1529    // the second whole-pack verification pass is skipped.
1530    crate::index_pack::ingest_received_pack_path_with_progress(
1531        pack_path,
1532        dest_odb,
1533        &crate::index_pack::IngestPackOptions {
1534            fix_thin: pack_opts.thin,
1535            skip_post_index_verify: true,
1536            ..Default::default()
1537        },
1538        None,
1539        Some(cancel),
1540    )?;
1541    refresh_local_odb_after_link(dest_odb);
1542    Ok(())
1543}
1544
1545/// The objects of `send` that `peer_odb` does not store on disk.
1546///
1547/// Uses the on-disk check rather than [`Odb::exists`], which reports the empty
1548/// tree as present even without an object file: the result may be packed
1549/// as-is, and the peer must end up with every object it references.
1550fn objects_missing_from_peer(peer_odb: &Odb, send: &[ObjectId]) -> Result<Vec<ObjectId>> {
1551    if !peer_odb.uses_files_primary() {
1552        if peer_odb.custom_primary_is_empty()? {
1553            return Ok(send.to_vec());
1554        }
1555        return peer_odb.missing_materialized_many(send);
1556    }
1557    peer_odb.reprepare_local_packs();
1558    Ok(send
1559        .iter()
1560        .copied()
1561        .filter(|oid| !peer_odb.exists_materialized_local_without_rescan(oid))
1562        .collect())
1563}
1564
1565/// Stream the pack that copies `missing` (a subset of `send_set`) into the peer to `w`.
1566///
1567/// For a non-thin pack, `missing` is already the exact set the peer lacks (the walk
1568/// stopped at every object the peer stores, with or without `haves`), so it is packed
1569/// as-is. Re-enumerating from `missing` instead would walk its whole closure again (for
1570/// a fetch with `haves`, a second reachability pass over everything the peer has) and
1571/// re-send objects just linked into the peer, such as every packed object reachable
1572/// from a loose annotated tag. A thin pack, or a shallow peer, defers to
1573/// [`write_pack_for_local_fetch`], which negotiates against the peer's `haves`.
1574/// Stops with [`Error::Cancelled`] once `stop` fires.
1575#[allow(clippy::too_many_arguments)]
1576fn write_pack_for_local_transfer(
1577    source_odb: &Odb,
1578    send_set: &PeerSendSet,
1579    missing: &[ObjectId],
1580    peer_odb: &Odb,
1581    haves: &[ObjectId],
1582    source_shallow: &HashSet<ObjectId>,
1583    have_shallow: &HashSet<ObjectId>,
1584    opts: &PackBuildOptions,
1585    stop: &crate::progress::CancelSignal<'_>,
1586    w: &mut dyn Write,
1587    copy: Option<(&mut LocalCopyProgress, &mut dyn Progress)>,
1588) -> Result<()> {
1589    if !opts.thin && have_shallow.is_empty() {
1590        let pack_budget = missing.len() as u64;
1591        if let Some((copy, progress)) = copy {
1592            let mut pack_reported = 0u64;
1593            let mut on_pack = |n: u64| {
1594                let add = n.min(pack_budget.saturating_sub(pack_reported));
1595                if add > 0 {
1596                    pack_reported += add;
1597                    copy.add(progress, add);
1598                }
1599            };
1600            crate::pack_objects::write_pack_from_send_list(
1601                source_odb,
1602                missing,
1603                opts,
1604                &send_set.name_hashes,
1605                stop,
1606                w,
1607                Some(&mut on_pack),
1608            )?;
1609        } else {
1610            crate::pack_objects::write_pack_from_send_list(
1611                source_odb,
1612                missing,
1613                opts,
1614                &send_set.name_hashes,
1615                stop,
1616                w,
1617                None,
1618            )?;
1619        }
1620    } else {
1621        write_pack_for_local_fetch(
1622            source_odb,
1623            missing,
1624            peer_odb,
1625            haves,
1626            source_shallow,
1627            have_shallow,
1628            opts,
1629            stop,
1630            w,
1631        )?;
1632    }
1633    Ok(())
1634}
1635
1636/// Objects a local transfer must send, from [`collect_reachable_excluding_peer`].
1637struct PeerSendSet {
1638    /// Objects missing from the peer, in walk order.
1639    objects: Vec<ObjectId>,
1640    /// Pack name hash of each blob's and subtree's path, for delta-window ordering.
1641    name_hashes: HashMap<ObjectId, u32>,
1642}
1643
1644/// Like [`collect_reachable_excluding`], but treats every object already stored in
1645/// `peer_odb` as a boundary (plus explicit `peer_boundary` tips). Used for local
1646/// file fetch/push so incremental transfers do not walk full history from `haves`.
1647/// `stop` is polled for every object read.
1648#[allow(clippy::too_many_arguments)]
1649fn collect_reachable_excluding_peer(
1650    source_odb: &Odb,
1651    roots: &[ObjectId],
1652    peer_odb: &Odb,
1653    peer_boundary: &HashSet<ObjectId>,
1654    skip_missing: bool,
1655    shallow_grafts: &HashSet<ObjectId>,
1656    stop: &crate::progress::CancelSignal<'_>,
1657    enum_sink: &mut dyn ProgressSink,
1658    enum_last: &mut u64,
1659) -> Result<PeerSendSet> {
1660    use crate::pack_name_hash::pack_name_hash_extend;
1661
1662    stop.poll()?;
1663    if peer_boundary.is_empty() && shallow_grafts.is_empty() {
1664        if let Some(send_set) = bitmap_send_set(source_odb, roots, peer_odb, enum_sink, enum_last)?
1665        {
1666            stop.poll()?;
1667            return Ok(send_set);
1668        }
1669    }
1670
1671    let mut visited: HashSet<ObjectId> = HashSet::new();
1672    let mut ordered: Vec<ObjectId> = Vec::new();
1673    let mut name_hashes: HashMap<ObjectId, u32> = HashMap::new();
1674    // Each queued object carries the name hash of its directory prefix ("dir/").
1675    let mut queue: VecDeque<(ObjectId, u32)> = VecDeque::new();
1676
1677    // The peer is not written during the walk, so one pack-directory refresh is
1678    // enough; a stale miss only means sending an object the peer already has.
1679    let peer_is_files = peer_odb.uses_files_primary();
1680    if peer_is_files {
1681        peer_odb.reprepare_local_packs();
1682    }
1683    let at_boundary = |oid: ObjectId| {
1684        peer_boundary.contains(&oid)
1685            || (peer_is_files && peer_odb.exists_materialized_local_without_rescan(&oid))
1686    };
1687
1688    // `visited` also remembers boundary objects so each object is probed once.
1689    // Blobs named by tree entries are recorded without being read: they have no
1690    // outgoing links, and a missing one still fails when the pack is written.
1691    let enqueue = |oid: ObjectId,
1692                   prefix_hash: Option<u32>,
1693                   queue: &mut VecDeque<(ObjectId, u32)>,
1694                   visited: &mut HashSet<ObjectId>,
1695                   ordered: &mut Vec<ObjectId>|
1696     -> bool {
1697        if !visited.insert(oid) || at_boundary(oid) {
1698            return false;
1699        }
1700        ordered.push(oid);
1701        if let Some(prefix_hash) = prefix_hash {
1702            queue.push_back((oid, prefix_hash));
1703        }
1704        true
1705    };
1706
1707    for &root in roots {
1708        enqueue(root, Some(0), &mut queue, &mut visited, &mut ordered);
1709    }
1710
1711    while let Some((oid, prefix_hash)) = queue.pop_front() {
1712        stop.poll()?;
1713        let obj = match source_odb.read(&oid) {
1714            Ok(o) => o,
1715            Err(_) if skip_missing => continue,
1716            Err(e) => return Err(e),
1717        };
1718        match obj.kind {
1719            ObjectKind::Commit => {
1720                let commit = parse_commit(&obj.data)?;
1721                if !shallow_grafts.contains(&oid) {
1722                    for parent in commit.parents {
1723                        enqueue(parent, Some(0), &mut queue, &mut visited, &mut ordered);
1724                    }
1725                }
1726                enqueue(commit.tree, Some(0), &mut queue, &mut visited, &mut ordered);
1727            }
1728            ObjectKind::Tree => {
1729                for entry in parse_tree(&obj.data)? {
1730                    if entry.mode == 0o160000 {
1731                        continue;
1732                    }
1733                    let path_hash = pack_name_hash_extend(prefix_hash, &entry.name);
1734                    let queued = if entry.mode & 0o170000 == 0o040000 {
1735                        let dir_hash = pack_name_hash_extend(path_hash, b"/");
1736                        enqueue(
1737                            entry.oid,
1738                            Some(dir_hash),
1739                            &mut queue,
1740                            &mut visited,
1741                            &mut ordered,
1742                        )
1743                    } else {
1744                        enqueue(entry.oid, None, &mut queue, &mut visited, &mut ordered)
1745                    };
1746                    if queued {
1747                        name_hashes.insert(entry.oid, path_hash);
1748                    }
1749                }
1750            }
1751            ObjectKind::Tag => {
1752                let tag = parse_tag(&obj.data)?;
1753                enqueue(
1754                    tag.object,
1755                    Some(prefix_hash),
1756                    &mut queue,
1757                    &mut visited,
1758                    &mut ordered,
1759                );
1760            }
1761            ObjectKind::Blob => {}
1762        }
1763        let n = ordered.len() as u64;
1764        crate::progress::report_object_progress_stride(enum_sink, n, enum_last, None);
1765    }
1766
1767    Ok(PeerSendSet {
1768        objects: ordered,
1769        name_hashes,
1770    })
1771}
1772
1773/// The send set of a transfer with no peer tips, answered from the source's
1774/// reachability bitmap instead of reading every commit and tree.
1775///
1776/// Returns `None` when the source has no usable bitmap.
1777fn bitmap_send_set(
1778    source_odb: &Odb,
1779    roots: &[ObjectId],
1780    peer_odb: &Odb,
1781    enum_sink: &mut dyn ProgressSink,
1782    enum_last: &mut u64,
1783) -> Result<Option<PeerSendSet>> {
1784    let no_haves = HashSet::new();
1785    let Some((reachable, _)) = crate::pack_object_select::try_bitmap_reachable_for_upload(
1786        source_odb,
1787        roots,
1788        &[],
1789        None,
1790        &no_haves,
1791    )?
1792    else {
1793        return Ok(None);
1794    };
1795    let mut objects: Vec<ObjectId> = Vec::new();
1796    if peer_odb.uses_files_primary() {
1797        peer_odb.reprepare_local_packs();
1798        for oid in reachable
1799            .iter_grouped_by_kind()
1800            .filter(|oid| !peer_odb.exists_materialized_local_without_rescan(oid))
1801        {
1802            objects.push(oid);
1803            let n = objects.len() as u64;
1804            crate::progress::report_object_progress_stride(enum_sink, n, enum_last, None);
1805        }
1806    } else {
1807        for oid in reachable.iter_grouped_by_kind() {
1808            objects.push(oid);
1809            let n = objects.len() as u64;
1810            crate::progress::report_object_progress_stride(enum_sink, n, enum_last, None);
1811        }
1812    }
1813    let name_hashes = crate::pack_object_select::name_hashes_for_send(
1814        reachable.bitmap_index(),
1815        &objects,
1816        &no_haves,
1817    );
1818    Ok(Some(PeerSendSet {
1819        objects,
1820        name_hashes,
1821    }))
1822}
1823
1824/// `fetch_local` function.
1825pub fn fetch_local(
1826    local_git_dir: &Path,
1827    remote_git_dir: &Path,
1828    opts: &FetchOptions,
1829) -> Result<FetchOutcome> {
1830    fetch_local_with_progress(
1831        local_git_dir,
1832        remote_git_dir,
1833        opts,
1834        &mut NoProgress,
1835        &crate::progress::NeverCancel,
1836    )
1837}
1838
1839/// Result of matching remote refs to fetch refspecs.
1840pub(crate) struct FetchMatchSet {
1841    pub(crate) matched: Vec<MatchedRef>,
1842    pub(crate) positive: Vec<RefspecItem>,
1843    pub(crate) negatives: Vec<RefspecItem>,
1844    pub(crate) explicit_refspec_fetch: bool,
1845}
1846
1847/// Match remote refs to fetch refspecs (shared by local, wire, and bundle fetch).
1848pub(crate) fn build_fetch_matched_set(
1849    opts: &FetchOptions,
1850    remote_refs: &[(String, ObjectId)],
1851    remote_head_oid: Option<ObjectId>,
1852) -> Result<FetchMatchSet> {
1853    use crate::fetch_request::{FetchRefspecInput, FetchRefspecPlan, FetchRemoteAdvertisement};
1854
1855    let remote = FetchRemoteAdvertisement {
1856        refs: remote_refs,
1857        head_oid: remote_head_oid,
1858    };
1859    let plan = FetchRefspecPlan::resolve(
1860        &FetchRefspecInput {
1861            user_refspecs: opts.refspecs.clone(),
1862            configured_refspecs: opts.configured_refspecs.clone(),
1863            remote_name: opts.remote_name.clone(),
1864            allow_missing_explicit_for_prune: opts.prune,
1865        },
1866        remote,
1867    )?;
1868    let explicit_refspec_fetch = plan.is_explicit();
1869
1870    let mut negatives = match &plan {
1871        FetchRefspecPlan::Pattern { negatives, .. } | FetchRefspecPlan::Mixed { negatives, .. } => {
1872            negatives.clone()
1873        }
1874    };
1875    for spec in &opts.negative_refspecs {
1876        let item = parse_fetch_refspec(spec)
1877            .map_err(|e| Error::Message(format!("invalid negative refspec '{spec}': {e}")))?;
1878        negatives.push(item);
1879    }
1880
1881    let mut positive = match &plan {
1882        FetchRefspecPlan::Pattern { positive, .. } => positive.clone(),
1883        FetchRefspecPlan::Mixed {
1884            pattern_positive, ..
1885        } => {
1886            let mut positive = pattern_positive.clone();
1887            for spec in &opts.refspecs {
1888                if let Ok(item) = parse_fetch_refspec(spec) {
1889                    if !item.negative && !item.pattern && !item.matching {
1890                        positive.push(item);
1891                    }
1892                }
1893            }
1894            positive
1895        }
1896    };
1897    if opts.prune {
1898        crate::fetch_request::supplement_positive_for_explicit_prune(
1899            &mut positive,
1900            &opts.configured_refspecs,
1901            opts.remote_name.as_deref(),
1902        );
1903    }
1904
1905    let mut matched =
1906        crate::fetch_request::matched_refs_with_negatives(&plan, remote, &negatives, opts.prune)?;
1907    if opts.force {
1908        for m in &mut matched {
1909            if !m.auto_followed {
1910                m.force = true;
1911            }
1912        }
1913    }
1914    Ok(FetchMatchSet {
1915        matched,
1916        positive,
1917        negatives,
1918        explicit_refspec_fetch,
1919    })
1920}
1921
1922/// Enforce Git fetch ref-update rules for explicit refspec fetches.
1923pub(crate) fn enforce_fetch_ref_update(
1924    local_ref: &str,
1925    old: Option<ObjectId>,
1926    new: ObjectId,
1927    mode: UpdateMode,
1928    opts: &FetchOptions,
1929    explicit_refspec_fetch: bool,
1930    local_repo: Option<&crate::repo::Repository>,
1931) -> Result<()> {
1932    if opts.dry_run {
1933        return Ok(());
1934    }
1935    let would_write = matches!(
1936        mode,
1937        UpdateMode::New | UpdateMode::FastForward | UpdateMode::Forced
1938    );
1939    if let Some(short) = local_ref.strip_prefix("refs/heads/") {
1940        if old.is_some()
1941            && old != Some(new)
1942            && (would_write || mode == UpdateMode::NonFastForwardRejected)
1943        {
1944            if let Some(repo) = local_repo {
1945                if crate::worktree::find_worktree_with_branch_checked_out(repo, short, None)?
1946                    .is_some()
1947                {
1948                    return Err(Error::FetchIntoCheckedOutBranch {
1949                        branch: short.to_owned(),
1950                    });
1951                }
1952            }
1953        }
1954    }
1955    let _ = explicit_refspec_fetch;
1956    Ok(())
1957}
1958
1959/// Fail with [`Error::RemoteUnreadable`] unless `remote_git_dir` is a Git directory.
1960///
1961/// A missing or non-repository path must not read as an empty remote.
1962pub(crate) fn ensure_local_remote_repository(remote_git_dir: &Path) -> Result<()> {
1963    if crate::submodule_gitdir::is_valid_git_dir(remote_git_dir) {
1964        return Ok(());
1965    }
1966    Err(Error::remote_unreadable(
1967        remote_git_dir.display().to_string(),
1968        RemoteUnreadableReason::RepositoryNotFound,
1969    ))
1970}
1971
1972/// Like [`fetch_local`], reporting object copy progress through `progress`.
1973///
1974/// The local repository is opened with [`FetchOptions::environment`], so objects land in its
1975/// `GIT_OBJECT_DIRECTORY` when that is set.
1976pub fn fetch_local_with_progress(
1977    local_git_dir: &Path,
1978    remote_git_dir: &Path,
1979    opts: &FetchOptions,
1980    progress: &mut dyn Progress,
1981    cancel: &dyn crate::progress::Cancel,
1982) -> Result<FetchOutcome> {
1983    crate::progress::poll_cancel(progress, cancel)?;
1984    ensure_local_remote_repository(remote_git_dir)?;
1985    let local = crate::fetch::open_fetch_target(local_git_dir, opts)?;
1986    fetch_local_into(&local, remote_git_dir, opts, progress, cancel)
1987}
1988
1989/// Like [`fetch_local_with_progress`], copying objects into `local`'s object store.
1990pub(crate) fn fetch_local_into(
1991    local: &crate::repo::Repository,
1992    remote_git_dir: &Path,
1993    opts: &FetchOptions,
1994    progress: &mut dyn Progress,
1995    cancel: &dyn crate::progress::Cancel,
1996) -> Result<FetchOutcome> {
1997    crate::progress::poll_cancel(progress, cancel)?;
1998    ensure_local_remote_repository(remote_git_dir)?;
1999    let local_git_dir = local.git_dir();
2000    validate_fetch_depth_local(local_git_dir, opts)?;
2001
2002    let remote_odb = open_odb(remote_git_dir);
2003    let remote_algo = remote_odb.hash_algo();
2004    let reopened = sync_empty_destination_object_format(local_git_dir, remote_algo, opts)?
2005        .then(|| reopen_after_format_change(local))
2006        .transpose()?;
2007    let local = reopened.as_ref().unwrap_or(local);
2008    let local_git_dir = local.git_dir();
2009    let local_odb = local.odb();
2010
2011    // 1. Enumerate remote refs (with HEAD symref for the default branch).
2012    let remote_entries = crate::remote::list_refs_from_git_dir_for_fetch(
2013        remote_git_dir,
2014        Some(&remote_odb),
2015        &opts.refspecs,
2016    )
2017    .map_err(crate::error::Error::from)?;
2018
2019    let mut default_branch = None;
2020    let mut remote_head_oid = None;
2021    // remote ref name -> oid (excluding HEAD and peeled `^{}` entries).
2022    let mut remote_refs: Vec<(String, ObjectId)> = Vec::new();
2023    for entry in &remote_entries {
2024        if entry.name == "HEAD" {
2025            remote_head_oid = Some(entry.oid);
2026            default_branch = entry
2027                .symref_target
2028                .as_ref()
2029                .map(|t| t.strip_prefix("refs/heads/").unwrap_or(t).to_owned());
2030            continue;
2031        }
2032        if entry.name.ends_with("^{}") {
2033            continue;
2034        }
2035        if !crate::refs::is_valid_fetch_advertised_ref(&entry.name) {
2036            continue;
2037        }
2038        remote_refs.push((entry.name.clone(), entry.oid));
2039    }
2040
2041    let FetchMatchSet {
2042        mut matched,
2043        positive,
2044        negatives,
2045        explicit_refspec_fetch,
2046    } = build_fetch_matched_set(opts, &remote_refs, remote_head_oid)?;
2047    let mut matched_oids: HashSet<ObjectId> = matched.iter().map(|m| m.oid).collect();
2048    let mut seen_remote_ref: HashSet<String> =
2049        matched.iter().map(|m| m.remote_ref.clone()).collect();
2050
2051    let local_shallow = crate::shallow::load_shallow_boundaries(local_git_dir);
2052    let remote_shallow = crate::shallow::load_shallow_boundaries(remote_git_dir);
2053
2054    let mut prep_enumerating = false;
2055    if !opts.dry_run {
2056        progress.start(ProgressPhase::EnumeratingObjects, None);
2057        prep_enumerating = true;
2058    }
2059
2060    // Explicitly requested tags participate in depth calculation; automatically
2061    // followed tags must only use the history selected by those fetch roots.
2062    if opts.tags == TagMode::All {
2063        apply_tag_mode(
2064            opts.tags,
2065            &remote_refs,
2066            &remote_odb,
2067            &remote_shallow,
2068            &negatives,
2069            &HashSet::new(),
2070            opts.force,
2071            &mut matched,
2072            &mut matched_oids,
2073            &mut seen_remote_ref,
2074        )?;
2075    }
2076    let shallow_response = if opts.has_deepen_request() {
2077        let shallow_wants: HashSet<ObjectId> = matched
2078            .iter()
2079            .map(|m| shallow_want_commit_tip(&remote_odb, m))
2080            .collect::<Result<_>>()?;
2081        let remote_repo =
2082            crate::repo::Repository::open(remote_git_dir, &RepositoryOptions::empty())?;
2083        let (wire_depth, deepen_relative) = opts
2084            .wire_deepen()
2085            .map(|(d, r)| (Some(d), r))
2086            .unwrap_or((None, false));
2087        let req = crate::serve::ShallowRequest {
2088            client_shallow: local_shallow.iter().copied().collect(),
2089            depth: wire_depth,
2090            deepen_since: opts
2091                .deepen_since
2092                .as_deref()
2093                .and_then(|s| s.parse::<i64>().ok()),
2094            deepen_not: Vec::new(),
2095            deepen_relative,
2096        };
2097        Some(crate::serve::compute_shallow_response(
2098            &remote_repo,
2099            &shallow_wants.into_iter().collect::<Vec<_>>(),
2100            &req,
2101        )?)
2102    } else {
2103        None
2104    };
2105    let copy_source_shallow = shallow_response
2106        .as_ref()
2107        .map(|resp| resp.pack_shallow_grafts.clone())
2108        .unwrap_or_else(|| remote_shallow.clone());
2109    let mut tag_shallow = copy_source_shallow.clone();
2110    if shallow_response.is_none() {
2111        tag_shallow.extend(local_shallow.iter().copied());
2112    }
2113
2114    let following_only = if opts.tags == TagMode::Following {
2115        let tags_present = crate::fetch::tags_already_local(local.refs(), &remote_refs);
2116        apply_tag_mode(
2117            opts.tags,
2118            &remote_refs,
2119            &remote_odb,
2120            &tag_shallow,
2121            &negatives,
2122            &tags_present,
2123            opts.force,
2124            &mut matched,
2125            &mut matched_oids,
2126            &mut seen_remote_ref,
2127        )?
2128    } else {
2129        HashSet::new()
2130    };
2131
2132    let mut following_tag_wants = if shallow_response.is_some() {
2133        // Branch roots already bring in lightweight targets. Only annotated tag
2134        // objects (and their chains) need additional wants in this response.
2135        local_odb
2136            .missing_oids(
2137                matched
2138                    .iter()
2139                    .filter(|m| {
2140                        following_only.contains(&m.oid)
2141                            && m.advertised_peel.is_some_and(|peel| peel != m.oid)
2142                    })
2143                    .map(|m| m.oid),
2144            )?
2145            .into_iter()
2146            .collect()
2147    } else {
2148        crate::fetch::following_tag_wants_with_local_peel(&matched, &following_only, local_odb)?
2149    };
2150    let shallow_request = opts.has_deepen_request() || !local_shallow.is_empty();
2151    if shallow_request && shallow_response.is_none() {
2152        let fetch_commit_oids = matched
2153            .iter()
2154            .filter(|m| !m.is_tag)
2155            .map(|m| m.oid)
2156            .collect();
2157        let peel_set = crate::fetch::following_peel_commit_set_for_shallow(
2158            &remote_odb,
2159            &fetch_commit_oids,
2160            opts.absolute_depth_for_tags(),
2161            &tag_shallow,
2162        )?;
2163        following_tag_wants.extend(
2164            crate::fetch::following_tag_wants_peel_among_fetched_commits(
2165                &matched,
2166                &following_only,
2167                &peel_set,
2168            ),
2169        );
2170    }
2171
2172    // Determine wants and copy only the required object set.
2173    let mut wants: Vec<ObjectId> = if shallow_request {
2174        matched_oids
2175            .iter()
2176            .copied()
2177            .filter(|oid| !following_only.contains(oid) || following_tag_wants.contains(oid))
2178            .collect()
2179    } else {
2180        local_odb.missing_oids(matched_oids.iter().copied())?
2181    };
2182    let (shallow_new, shallow_unshallow) = if let Some(resp) = shallow_response {
2183        for oid in resp.extra_wants.iter().chain(resp.shallow.iter()).copied() {
2184            if !wants.contains(&oid) {
2185                wants.push(oid);
2186            }
2187        }
2188        (resp.shallow, resp.unshallow)
2189    } else {
2190        (Vec::new(), Vec::new())
2191    };
2192    let mut pack_oids: HashSet<ObjectId> = HashSet::new();
2193    let mut transfer = TransferStats::default();
2194    if wants.is_empty() && prep_enumerating {
2195        progress.finish();
2196        prep_enumerating = false;
2197    }
2198    if !wants.is_empty() && !opts.dry_run {
2199        // Resolve exclusion tips only once the final shallow/deepen wants need a copy.
2200        let mut haves: Vec<ObjectId> = Vec::new();
2201        let mut have_seen: HashSet<ObjectId> = HashSet::new();
2202        for m in &matched {
2203            if let Some(local_ref) = &m.local_ref {
2204                if let Ok(old) = local.resolve_ref_oid(local_ref) {
2205                    if have_seen.insert(old) {
2206                        haves.push(old);
2207                    }
2208                }
2209            }
2210        }
2211
2212        let remote_cfg = config_for_git_dir(remote_git_dir);
2213        let pack_opts = PackBuildOptions::for_local_copy(remote_cfg.as_ref());
2214        let (oids, fetch_transfer) = copy_objects_for_local_fetch(
2215            local_git_dir,
2216            &remote_odb,
2217            local_odb,
2218            &wants,
2219            &haves,
2220            &copy_source_shallow,
2221            &local_shallow,
2222            !opts.has_deepen_request(),
2223            &pack_opts,
2224            opts,
2225            progress,
2226            cancel,
2227            prep_enumerating,
2228        )?;
2229        pack_oids = oids;
2230        transfer = fetch_transfer;
2231    }
2232
2233    if opts.initial_remote_fetch
2234        && !remote_shallow.is_empty()
2235        && !opts.has_deepen_request()
2236        && !opts.dry_run
2237    {
2238        let remote_shallow_vec: Vec<ObjectId> = remote_shallow.iter().copied().collect();
2239        crate::shallow::apply_shallow_updates(local_git_dir, &remote_shallow_vec, &[])?;
2240    }
2241    if (!shallow_new.is_empty() || !shallow_unshallow.is_empty()) && !opts.dry_run {
2242        crate::shallow::apply_shallow_updates(local_git_dir, &shallow_new, &shallow_unshallow)?;
2243    }
2244
2245    if opts.tags == TagMode::Following {
2246        let boundaries = crate::shallow::load_shallow_boundaries(local_git_dir);
2247        crate::fetch::retain_following_tags(local_odb, &mut matched, &pack_oids, &boundaries)?;
2248    }
2249
2250    // 4. Classify and apply ref updates. Ancestry checks use the local repo
2251    //    (or the bare remote during `--dry-run`, when objects are not copied).
2252    let dry_run_remote = if opts.dry_run {
2253        crate::repo::Repository::open(remote_git_dir, &RepositoryOptions::empty()).ok()
2254    } else {
2255        None
2256    };
2257    let classify_repo = if opts.dry_run {
2258        dry_run_remote.as_ref()
2259    } else {
2260        Some(local)
2261    };
2262
2263    let mut updates: Vec<RefUpdate> = Vec::new();
2264
2265    // Prune BEFORE writing the new tips. A stale tracking ref stored as a file
2266    // (e.g. `refs/remotes/origin/a`) otherwise blocks creating a nested ref the
2267    // same fetch introduces (`refs/remotes/origin/a/b`) with a "File exists"
2268    // directory/file conflict (matches `git fetch --prune` ordering).
2269    if opts.prune {
2270        prune_tracking_refs(local, &positive, &remote_refs, opts.dry_run, &mut updates)?;
2271    }
2272
2273    let tag_targets_ok = tag_refs_with_local_targets(
2274        local_odb,
2275        &remote_odb,
2276        matched.iter().filter(|m| m.dest_is_tag).map(|m| m.oid),
2277        &pack_oids,
2278    )?;
2279
2280    let mut store_batch: Vec<(String, crate::refs::store::RawRef)> =
2281        Vec::with_capacity(matched.len());
2282    for m in &matched {
2283        let Some(local_ref) = &m.local_ref else {
2284            // dst empty: fetched but not stored. Report as a no-store update.
2285            updates.push(RefUpdate {
2286                remote_ref: m.remote_ref.clone(),
2287                local_ref: None,
2288                old_oid: None,
2289                new_oid: Some(m.oid),
2290                mode: UpdateMode::NoChangeNeeded,
2291                note: Some("not stored (empty destination)".to_owned()),
2292            });
2293            continue;
2294        };
2295
2296        let old = local.resolve_ref_oid(local_ref).ok();
2297        let mode = classify_update(
2298            old.as_ref(),
2299            &m.oid,
2300            m.force,
2301            m.dest_is_tag,
2302            classify_repo,
2303            None,
2304        );
2305
2306        let write = matches!(
2307            mode,
2308            UpdateMode::New | UpdateMode::FastForward | UpdateMode::Forced
2309        );
2310        enforce_fetch_ref_update(
2311            local_ref,
2312            old,
2313            m.oid,
2314            mode,
2315            opts,
2316            explicit_refspec_fetch,
2317            classify_repo,
2318        )?;
2319        if write && !opts.dry_run {
2320            if !crate::refs::is_valid_storable_ref_name(local_ref) {
2321                updates.push(RefUpdate {
2322                    remote_ref: m.remote_ref.clone(),
2323                    local_ref: Some(local_ref.clone()),
2324                    old_oid: old,
2325                    new_oid: Some(m.oid),
2326                    mode,
2327                    note: Some("skipped (invalid ref name)".to_owned()),
2328                });
2329                continue;
2330            }
2331            if m.dest_is_tag && !tag_targets_ok.contains(&m.oid) {
2332                updates.push(RefUpdate {
2333                    remote_ref: m.remote_ref.clone(),
2334                    local_ref: Some(local_ref.clone()),
2335                    old_oid: old,
2336                    new_oid: Some(m.oid),
2337                    mode: UpdateMode::NoChangeNeeded,
2338                    note: Some("skipped (tag target not present locally)".to_owned()),
2339                });
2340                continue;
2341            }
2342            store_batch.push((local_ref.clone(), crate::refs::store::RawRef::Direct(m.oid)));
2343        }
2344
2345        updates.push(RefUpdate {
2346            remote_ref: m.remote_ref.clone(),
2347            local_ref: Some(local_ref.clone()),
2348            old_oid: old,
2349            new_oid: Some(m.oid),
2350            mode,
2351            note: None,
2352        });
2353    }
2354
2355    if let Some(remote) = opts.remote_name.as_deref() {
2356        if !opts.dry_run {
2357            if let Some(branch) = default_branch.as_deref() {
2358                let head_ref = format!("refs/remotes/{remote}/HEAD");
2359                if crate::refs::resolve_ref_in(local, &head_ref).is_err() {
2360                    let target = format!("refs/remotes/{remote}/{branch}");
2361                    let target_will_exist = store_batch.iter().any(|(name, _)| *name == target)
2362                        || crate::refs::resolve_ref_in(local, &target).is_ok();
2363                    if target_will_exist {
2364                        store_batch.push((head_ref, crate::refs::store::RawRef::Symbolic(target)));
2365                    }
2366                }
2367            }
2368        }
2369    }
2370    if !opts.dry_run && !store_batch.is_empty() {
2371        let fetch_reflog = crate::fetch::prepare_fetch_apply_reflog(local, opts)?;
2372        let fetch_reflog_ctx = fetch_reflog
2373            .as_ref()
2374            .map(crate::fetch::PreparedFetchReflog::context);
2375        apply_fetch_ref_updates(
2376            local,
2377            store_batch,
2378            &mut updates,
2379            FetchRefRejectHint {
2380                prune: opts.prune,
2381                positive: &positive,
2382                remote_refs: &remote_refs,
2383                remote_name: opts.remote_name.as_deref(),
2384            },
2385            opts.initial_remote_fetch,
2386            fetch_reflog_ctx.as_ref(),
2387        )?;
2388    }
2389
2390    crate::fetch::finish_initial_remote_fetch_layout(local, opts, default_branch.as_deref())?;
2391    if let Some(remote) = opts.remote_name.as_deref() {
2392        crate::fetch::ensure_remote_tracking_head_symref(
2393            local,
2394            remote,
2395            default_branch.as_deref(),
2396            opts.dry_run,
2397        )?;
2398    }
2399    finish_anonymous_url_fetch(
2400        local_git_dir,
2401        opts,
2402        explicit_refspec_fetch,
2403        default_branch.as_deref(),
2404        &remote_refs,
2405        &matched,
2406    )?;
2407    let new_shallow = crate::shallow::dedupe_shallow_list(if opts.has_deepen_request() {
2408        shallow_new
2409    } else if opts.initial_remote_fetch {
2410        remote_shallow.iter().copied().collect()
2411    } else {
2412        Vec::new()
2413    });
2414    Ok(FetchOutcome {
2415        updates,
2416        default_branch,
2417        new_shallow,
2418        new_unshallow: shallow_unshallow,
2419        transfer,
2420        filter_sent: false,
2421        promisor: None,
2422    })
2423}
2424
2425/// Push refs and objects from one on-disk git repository into another, entirely
2426/// in-process (no subprocess, no wire protocol).
2427///
2428/// This is the local / `file://` push (send-pack) counterpart to
2429/// [`fetch_local`]. For each [`PushRefSpec`] it:
2430///
2431/// 1. Resolves the source oid from the LOCAL repo (for a non-delete update) and
2432///    reads the remote's current value of `dst`.
2433/// 2. Enforces the update rules and produces a [`PushRefResult`] with the right
2434///    [`crate::push_report::PushRefStatus`]:
2435///    * `expected_old` set and mismatching the remote's current value →
2436///      [`PushRefStatus::RejectStale`] (compare-and-swap / force-with-lease).
2437///    * deletion → succeed when present, or [`PushRefStatus::UpToDate`] when the
2438///      ref is already gone.
2439///    * non-fast-forward (remote current is not an ancestor of the source)
2440///      without `force` → [`PushRefStatus::RejectNonFastForward`]; with `force`
2441///      it is accepted and reported as forced.
2442///    * unchanged (remote already at the source) → [`PushRefStatus::UpToDate`].
2443///    * otherwise [`PushRefStatus::Ok`].
2444/// 3. For accepted non-delete updates, copies the minimal object closure from the
2445///    LOCAL odb into a quarantine of the REMOTE odb via [`build_pack`] and
2446///    [`crate::index_pack::ingest_received_pack`], excluding objects already
2447///    reachable from the remote's existing ref tips.
2448/// 4. Applies the remote's `receive.*` policy (deny non-fast-forwards, deletes and the
2449///    current branch, hidden refs) as [`crate::serve::receive_pack`] does.
2450/// 5. Runs the remote's receive hooks with Git's arguments, stdin and environment:
2451///    `pre-receive` over every update (with the quarantine variables, so it can read
2452///    the pushed objects), then `update` per ref once the objects are installed. A
2453///    non-zero `pre-receive` refuses every update and keeps the objects out of the
2454///    remote; a non-zero `update` refuses its ref. Refusals are
2455///    [`PushRefStatus::RemoteRejected`] results, not errors.
2456/// 6. Applies the ref changes on the remote as a compare-and-swap against the value
2457///    read in step 1, like receive-pack: an update or delete needs the ref to still
2458///    hold that oid, and a create needs it to still be absent, even with `force`. A
2459///    ref that another writer created in between is reported as
2460///    [`PushRefStatus::RejectAlreadyExists`]; one that moved is
2461///    [`PushRefStatus::RejectStale`]. Then `post-receive` and `post-update` run over
2462///    the refs that were written.
2463///
2464/// Objects are copied into a quarantine directory first and move into the remote's
2465/// object store only when some update is going ahead. With `opts.dry_run` nothing is
2466/// copied or written and no receive hook runs, as with `git push --dry-run`. Hook
2467/// output is returned in [`PushOutcome::remote_messages`]. Through
2468/// [`crate::remote::Remote::push`], hooks run with the pushing repository's environment
2469/// minus the variables that locate it; `push_local` opens the local repository with
2470/// [`RepositoryOptions::empty`], so the hooks it runs get an empty environment (no `PATH`).
2471///
2472/// When `opts.atomic` is set and any ref is rejected (by the update rules, the
2473/// remote's policy or a hook), no ref is written and every otherwise-accepted ref is
2474/// reported as [`PushRefStatus::AtomicPushFailed`].
2475///
2476/// Both repositories must use the same object hash algorithm; the hash width is
2477/// threaded through [`Odb::hash_algo`] so SHA-256 repos work.
2478///
2479/// The client-side `pre-push` hook is not run here; [`crate::remote::Remote::push`] runs it.
2480///
2481/// # Errors
2482///
2483/// Returns an error if either repository cannot be opened, if a source object is
2484/// missing from the local odb, or on I/O failure while writing objects or refs.
2485/// Returns [`Error::PushOptionsUnsupported`] when `opts.push_options` is non-empty and
2486/// the remote does not set `receive.advertisePushOptions`.
2487//
2488// TODO(phase: remote transports): `git://`, `http(s)`, and `ssh` push
2489// (receive-pack handshake + report-status parsing + credential helpers) are out
2490// of scope here and live in a later phase.
2491pub fn push_local(
2492    local_git_dir: &Path,
2493    remote_git_dir: &Path,
2494    refs: &[PushRefSpec],
2495    opts: &PushOptions,
2496    cancel: &dyn crate::progress::Cancel,
2497) -> Result<PushOutcome> {
2498    let local = crate::repo::Repository::open(local_git_dir, &RepositoryOptions::empty())?;
2499    push_local_from(
2500        &local,
2501        remote_git_dir,
2502        refs,
2503        opts,
2504        &mut crate::fetch::NoProgress,
2505        cancel,
2506        None,
2507    )
2508}
2509
2510/// Like [`push_local`], reading the objects to send through `local`'s object store,
2511/// running `pre_push` after every update is decided and before anything is written to the
2512/// remote, and relaying the remote's hook output to [`crate::fetch::Progress::message`] as a
2513/// wire push relays side-band output.
2514pub(crate) fn push_local_from(
2515    local: &crate::repo::Repository,
2516    remote_git_dir: &Path,
2517    refs: &[PushRefSpec],
2518    opts: &PushOptions,
2519    progress: &mut dyn crate::fetch::Progress,
2520    cancel: &dyn crate::progress::Cancel,
2521    pre_push: Option<&PrePushGate<'_>>,
2522) -> Result<PushOutcome> {
2523    let local_git_dir = local.git_dir();
2524    let mut progress_sink = crate::progress::NullProgress;
2525    crate::progress::poll_cancel(&mut progress_sink, cancel)?;
2526    ensure_local_remote_repository(remote_git_dir)?;
2527    let remote_repo =
2528        crate::repo::Repository::open(remote_git_dir, &local.peer_repository_options())?;
2529    let remote_cfg = remote_repo
2530        .config()
2531        .unwrap_or_else(|_| std::sync::Arc::new(crate::config::ConfigSet::new()));
2532    if !opts.push_options.is_empty()
2533        && !remote_cfg
2534            .get_bool("receive.advertisepushoptions")
2535            .and_then(|r| r.ok())
2536            .unwrap_or(false)
2537    {
2538        return Err(Error::PushOptionsUnsupported);
2539    }
2540
2541    let local_odb = local.odb();
2542
2543    // The remote's existing ref tips become the `haves` for pack building, so the
2544    // copied object closure excludes everything the remote already has.
2545    let remote_have_tips: Vec<ObjectId> = match &opts.remote_ref_tips {
2546        Some(tips) => tips.clone(),
2547        None => crate::refs::list_refs(remote_git_dir, "refs/")?
2548            .into_iter()
2549            .map(|(_, oid)| oid)
2550            .collect(),
2551    };
2552
2553    // First pass: decide each ref's status without mutating anything.
2554    let mut decisions: Vec<PushDecision> = Vec::with_capacity(refs.len());
2555    for spec in refs {
2556        decisions.push(decide_push(spec, local_odb, remote_git_dir, Some(local))?);
2557    }
2558    if let Some(gate) = pre_push {
2559        gate.check(refs.iter().zip(decisions.iter().map(|d| &d.result)))?;
2560    }
2561
2562    let push_wants: Vec<ObjectId> = decisions
2563        .iter()
2564        .filter(|d| d.apply && !opts.dry_run)
2565        .filter_map(|d| match &d.action {
2566            PushAction::Update(src) => Some(*src),
2567            _ => None,
2568        })
2569        .collect();
2570    // Pushed objects wait in a quarantine until the receive hooks accept the push, so a
2571    // declined or refused push leaves nothing behind in the remote's object store.
2572    let mut quarantine = None;
2573    if !push_wants.is_empty() {
2574        crate::progress::poll_cancel(&mut progress_sink, cancel)?;
2575        let local_cfg = config_for_git_dir(local_git_dir);
2576        let pack_opts = if remote_have_tips.is_empty() {
2577            PackBuildOptions::for_local_copy(local_cfg.as_ref())
2578        } else {
2579            PackBuildOptions::for_local_push(local_cfg.as_ref())
2580        };
2581        let empty_shallow = HashSet::new();
2582        let remote_shallow = crate::shallow::load_shallow_boundaries(remote_git_dir);
2583        let q = crate::receive_quarantine::ReceiveQuarantine::create(
2584            remote_repo.odb(),
2585            remote_git_dir,
2586        )?;
2587        let q_odb = q.odb();
2588        copy_objects_for_local_push(
2589            local_odb,
2590            remote_repo.odb(),
2591            &q_odb,
2592            &push_wants,
2593            &remote_have_tips,
2594            &empty_shallow,
2595            &remote_shallow,
2596            &pack_opts,
2597            cancel,
2598        )?;
2599        quarantine = Some((q, q_odb));
2600    }
2601
2602    // What a receive-pack would be sent: every update still going ahead, before the
2603    // remote's own checks refuse any of them.
2604    let received: Vec<crate::serve::RefCommand> = if opts.dry_run {
2605        Vec::new()
2606    } else {
2607        decisions
2608            .iter()
2609            .filter(|d| d.apply)
2610            .filter_map(PushDecision::ref_command)
2611            .collect()
2612    };
2613
2614    #[cfg(test)]
2615    tests::run_push_hook(tests::PushHookPoint::BeforeReceivePolicy);
2616
2617    apply_remote_receive_policy(
2618        &remote_repo,
2619        local_git_dir,
2620        opts.dry_run,
2621        quarantine.as_ref().map(|(_, odb)| odb),
2622        &mut decisions,
2623    )?;
2624
2625    if opts.atomic && decisions.iter().any(|d| d.result.status.is_error()) {
2626        fail_atomic_push(&mut decisions);
2627        return Ok(PushOutcome {
2628            results: decisions.into_iter().map(|d| d.result).collect(),
2629            remote_messages: Vec::new(),
2630        });
2631    }
2632
2633    let scripts =
2634        crate::serve::ReceiveHookScripts::new(&remote_repo, &opts.push_options, Vec::new());
2635    let mut remote_messages = Vec::new();
2636    if !received.is_empty() {
2637        let commands: Vec<&crate::serve::RefCommand> = received.iter().collect();
2638        let pre = scripts.pre_receive(&commands, quarantine.as_ref().map(|(q, _)| q));
2639        relay_hook_output(progress, &mut remote_messages, &pre);
2640        if !pre.is_ok() {
2641            for d in decisions.iter_mut().filter(|d| d.apply) {
2642                d.refuse(&crate::serve::RefUpdateRefusal::PreReceiveHookDeclined);
2643            }
2644        }
2645    }
2646    let mut migrated_pack_keeps = Vec::new();
2647    if decisions.iter().any(|d| d.apply) && !opts.dry_run {
2648        if let Some((mut q, _)) = quarantine.take() {
2649            migrated_pack_keeps = q.migrate()?;
2650            remote_repo.odb().invalidate_packs();
2651        }
2652        for d in decisions.iter_mut().filter(|d| d.apply) {
2653            let Some(cmd) = d.ref_command() else {
2654                continue;
2655            };
2656            let update = scripts.update(&cmd);
2657            relay_hook_output(progress, &mut remote_messages, &update);
2658            if !update.is_ok() {
2659                d.refuse(&crate::serve::RefUpdateRefusal::UpdateHookDeclined);
2660                // receive-pack names the declined ref to the pusher.
2661                // hygiene: Git wire-compatible remote message, relayed like receive-pack's
2662                let line = format!("error: hook declined to update {}", d.result.remote_ref);
2663                progress.message(format!("{line}\n").as_bytes());
2664                remote_messages.push(line);
2665            }
2666        }
2667        if opts.atomic && decisions.iter().any(|d| d.result.status.is_error()) {
2668            fail_atomic_push(&mut decisions);
2669        }
2670    }
2671
2672    #[cfg(test)]
2673    tests::run_push_hook(tests::PushHookPoint::BeforeRefCommit);
2674
2675    // Second pass: apply ref updates (one batch unless `atomic`, then batched non-atomic).
2676    let mut ref_batch: Vec<crate::refs::store::RefUpdate> = Vec::new();
2677    let mut non_atomic_batch: Vec<(usize, crate::refs::store::RefUpdate)> = Vec::new();
2678    for (idx, d) in decisions.iter_mut().enumerate() {
2679        if !d.apply || opts.dry_run {
2680            continue;
2681        }
2682        match &d.action {
2683            PushAction::Update(src) => {
2684                let update = push_store_ref_update(
2685                    d.result.remote_ref.as_str(),
2686                    Some(*src),
2687                    d.result.old_oid,
2688                );
2689                if opts.atomic {
2690                    ref_batch.push(update);
2691                } else {
2692                    non_atomic_batch.push((idx, update));
2693                }
2694            }
2695            PushAction::Delete => {
2696                let update =
2697                    push_store_ref_update(d.result.remote_ref.as_str(), None, d.result.old_oid);
2698                if opts.atomic {
2699                    ref_batch.push(update);
2700                } else {
2701                    non_atomic_batch.push((idx, update));
2702                }
2703            }
2704            PushAction::None => {}
2705        }
2706    }
2707    if opts.atomic && !opts.dry_run && !ref_batch.is_empty() {
2708        if let Err(err) = crate::refs::commit_ref_store_batch(remote_git_dir, &ref_batch) {
2709            apply_atomic_batch_ref_store_error(&mut decisions, &ref_batch, err);
2710        }
2711    } else if !opts.dry_run && !non_atomic_batch.is_empty() {
2712        let indices: Vec<_> = non_atomic_batch.iter().map(|(idx, _)| *idx).collect();
2713        let updates: Vec<_> = non_atomic_batch.into_iter().map(|(_, u)| u).collect();
2714        match crate::refs::commit_ref_updates_non_atomic_on_store(remote_repo.refs(), updates) {
2715            Ok(outcomes) => {
2716                for (idx, outcome) in indices.into_iter().zip(outcomes) {
2717                    if let Err(err) = outcome.result {
2718                        apply_ref_store_error_to_push_decision(
2719                            &mut decisions[idx],
2720                            &Error::RefStore(err),
2721                            false,
2722                        );
2723                    }
2724                }
2725            }
2726            Err(err) => {
2727                for idx in indices {
2728                    apply_ref_store_error_to_push_decision(&mut decisions[idx], &err, false);
2729                }
2730            }
2731        }
2732    }
2733
2734    if !opts.dry_run {
2735        let applied: Vec<crate::serve::RefCommand> = decisions
2736            .iter()
2737            .filter(|d| d.apply)
2738            .filter_map(PushDecision::ref_command)
2739            .collect();
2740        if !applied.is_empty() {
2741            let applied: Vec<&crate::serve::RefCommand> = applied.iter().collect();
2742            relay_hook_output(
2743                progress,
2744                &mut remote_messages,
2745                &scripts.post_receive(&applied),
2746            );
2747            relay_hook_output(
2748                progress,
2749                &mut remote_messages,
2750                &scripts.post_update(&applied),
2751            );
2752        }
2753    }
2754
2755    let results: Vec<_> = decisions.into_iter().map(|d| d.result).collect();
2756    if !opts.dry_run {
2757        if let Some(remote) = opts.tracking_remote.as_deref() {
2758            crate::branch_tracking::apply_push_remote_tracking_updates_in(local, remote, &results)?;
2759        }
2760    }
2761
2762    drop(migrated_pack_keeps);
2763
2764    Ok(PushOutcome {
2765        results,
2766        remote_messages,
2767    })
2768}
2769
2770/// An `atomic` push with a refused update applies none: demote every otherwise-accepted
2771/// update to [`PushRefStatus::AtomicPushFailed`].
2772fn fail_atomic_push(decisions: &mut [PushDecision]) {
2773    for d in decisions {
2774        if matches!(d.result.status, PushRefStatus::Ok) {
2775            d.result.status = PushRefStatus::AtomicPushFailed;
2776            d.apply = false;
2777        }
2778    }
2779}
2780
2781/// Pass what a receive hook wrote (stdout, then stderr) to `progress` and append it to
2782/// `messages`, one entry per non-empty line, as a wire push handles the remote's side-band
2783/// hook output.
2784fn relay_hook_output(
2785    progress: &mut dyn crate::fetch::Progress,
2786    messages: &mut Vec<String>,
2787    result: &crate::hooks::HookResult,
2788) {
2789    use crate::sideband_lines::{RemoteLine, SidebandLines};
2790
2791    let Some(output) = result.output() else {
2792        return;
2793    };
2794    let mut record = |line: RemoteLine| {
2795        if let RemoteLine::Line(text) = line {
2796            if !text.is_empty() {
2797                messages.push(text);
2798            }
2799        }
2800    };
2801    for stream in [&output.stdout, &output.stderr] {
2802        if !stream.is_empty() {
2803            progress.message(stream);
2804        }
2805        let mut lines = SidebandLines::new();
2806        lines.feed(stream, &mut record);
2807        lines.finish(&mut record);
2808    }
2809}
2810
2811/// Object database for receive-policy checks during local push.
2812///
2813/// Matches wire receive-pack: the pusher's objects are primary and the remote's
2814/// object store is consulted via alternates so fast-forward walks see both the
2815/// current remote tip and objects not yet copied. After a real push copies
2816/// objects, the remote repository odb alone is enough.
2817fn push_policy_object_lookup(remote_git_dir: &Path, local_git_dir: &Path) -> Odb {
2818    Odb::new(&objects_dir(local_git_dir)).with_env_alternate_dirs(vec![objects_dir(remote_git_dir)])
2819}
2820
2821/// Apply the remote repository's `receive.*` policy to updates that would mutate refs.
2822///
2823/// `quarantine` holds the objects a real push copied; a dry run copied none and looks
2824/// objects up in the pusher's store as well.
2825fn apply_remote_receive_policy(
2826    remote_repo: &crate::repo::Repository,
2827    local_git_dir: &Path,
2828    dry_run: bool,
2829    quarantine: Option<&Odb>,
2830    decisions: &mut [PushDecision],
2831) -> Result<()> {
2832    if !decisions.iter().any(|d| d.apply) {
2833        return Ok(());
2834    }
2835    let cfg = remote_repo
2836        .config()
2837        .unwrap_or_else(|_| std::sync::Arc::new(crate::config::ConfigSet::new()));
2838    let policy = crate::serve::ReceivePolicy::from_config(cfg.as_ref());
2839    let hidden_refs = crate::hide_refs::hide_ref_patterns_receive(cfg.as_ref());
2840    let head_branch = crate::serve::head_branch(remote_repo);
2841    let checked_out = crate::serve::checked_out_branch(remote_repo);
2842    let branches = crate::serve::HeadBranches {
2843        head: head_branch.as_deref(),
2844        checked_out: checked_out.as_deref(),
2845    };
2846    let pre_copy_lookup =
2847        dry_run.then(|| push_policy_object_lookup(remote_repo.git_dir(), local_git_dir));
2848    let object_lookup = pre_copy_lookup
2849        .as_ref()
2850        .or(quarantine)
2851        .unwrap_or(remote_repo.odb());
2852
2853    for d in decisions.iter_mut().filter(|d| d.apply) {
2854        let Some(cmd) = d.ref_command() else {
2855            continue;
2856        };
2857        let refusal = match crate::serve::validate_ref_update(
2858            remote_repo,
2859            Some(object_lookup),
2860            &cmd,
2861            &policy,
2862            &hidden_refs,
2863            branches,
2864        ) {
2865            Ok(r) => r,
2866            Err(crate::serve::ServeError::Repository(e)) => return Err(e),
2867            Err(e) => return Err(Error::Message(e.to_string())),
2868        };
2869        if let Some(reason) = refusal {
2870            d.result.status = match (&reason, cmd.old) {
2871                (crate::serve::RefUpdateRefusal::StaleInfo, None) => {
2872                    PushRefStatus::RejectAlreadyExists
2873                }
2874                (crate::serve::RefUpdateRefusal::StaleInfo, Some(_)) => PushRefStatus::RejectStale,
2875                _ => PushRefStatus::RemoteRejected,
2876            };
2877            d.result.message = Some(match d.result.status {
2878                PushRefStatus::RejectAlreadyExists => "already exists".to_owned(),
2879                _ => reason.to_string(),
2880            });
2881            d.apply = false;
2882            d.action = PushAction::None;
2883        }
2884    }
2885    Ok(())
2886}
2887
2888/// The remote ref update for a decided push, compare-and-swapped against `observed_old`.
2889///
2890/// Like a receive-pack command, an update applies only if the ref still holds the value the
2891/// decision saw: `Some(oid)` must still be `oid`, and `None` (a create) must still be absent.
2892fn push_store_ref_update(
2893    refname: &str,
2894    new_oid: Option<ObjectId>,
2895    observed_old: Option<ObjectId>,
2896) -> crate::refs::store::RefUpdate {
2897    crate::refs::store::RefUpdate {
2898        name: refname.to_owned(),
2899        new_value: new_oid.map(crate::refs::store::RawRef::Direct),
2900        expected: match observed_old {
2901            Some(oid) => crate::refs::store::Expected::Oid(oid),
2902            None => crate::refs::store::Expected::Missing,
2903        },
2904        reflog: None,
2905        flags: Default::default(),
2906    }
2907}
2908
2909fn push_status_for_ref_store_error(err: &Error) -> PushRefStatus {
2910    match err {
2911        Error::RefStore(crate::refs::store::RefStoreError::ExpectedMismatch {
2912            expected: crate::refs::store::Expected::Missing,
2913            ..
2914        }) => PushRefStatus::RejectAlreadyExists,
2915        Error::RefStore(crate::refs::store::RefStoreError::ExpectedMismatch { .. }) => {
2916            PushRefStatus::RejectStale
2917        }
2918        _ => PushRefStatus::RemoteRejected,
2919    }
2920}
2921
2922/// The per-ref message for a ref store failure while applying a push update.
2923fn push_message_for_ref_store_error(err: &Error) -> String {
2924    match push_status_for_ref_store_error(err) {
2925        PushRefStatus::RejectAlreadyExists => "already exists".to_owned(),
2926        _ => err.to_string(),
2927    }
2928}
2929
2930fn ref_store_error_refname(err: &Error) -> Option<String> {
2931    let Error::RefStore(store_err) = err else {
2932        return None;
2933    };
2934    Some(match store_err {
2935        crate::refs::store::RefStoreError::ExpectedMismatch { name, .. }
2936        | crate::refs::store::RefStoreError::DuplicateUpdate { name }
2937        | crate::refs::store::RefStoreError::LockHeld { name }
2938        | crate::refs::store::RefStoreError::InvalidRefName { name }
2939        | crate::refs::store::RefStoreError::InvalidSymrefTarget { name, .. }
2940        | crate::refs::store::RefStoreError::NullOid { name }
2941        | crate::refs::store::RefStoreError::MissingObject { name, .. }
2942        | crate::refs::store::RefStoreError::NotACommit { name, .. }
2943        | crate::refs::store::RefStoreError::ObjectLookup { name, .. }
2944        | crate::refs::store::RefStoreError::NotFound { name }
2945        | crate::refs::store::RefStoreError::ReflogRewriteStale { name } => name.clone(),
2946        crate::refs::store::RefStoreError::NameUnavailable { reason } => match reason {
2947            crate::refs::RefnameUnavailable::SameBatch { refname, .. }
2948            | crate::refs::RefnameUnavailable::AncestorExists {
2949                new_ref: refname, ..
2950            }
2951            | crate::refs::RefnameUnavailable::DescendantExists {
2952                new_ref: refname, ..
2953            } => refname.clone(),
2954        },
2955        crate::refs::store::RefStoreError::SymrefLoop
2956        | crate::refs::store::RefStoreError::Corrupt(_)
2957        | crate::refs::store::RefStoreError::GitDirPathIo { .. }
2958        | crate::refs::store::RefStoreError::InvalidReflog(_)
2959        | crate::refs::store::RefStoreError::Backend(_)
2960        | crate::refs::store::RefStoreError::Hook(_) => return None,
2961    })
2962}
2963
2964fn apply_ref_store_error_to_push_decision(d: &mut PushDecision, err: &Error, _atomic: bool) {
2965    d.result.status = push_status_for_ref_store_error(err);
2966    d.result.message = Some(push_message_for_ref_store_error(err));
2967    d.apply = false;
2968}
2969
2970fn apply_atomic_batch_ref_store_error(
2971    decisions: &mut [PushDecision],
2972    batch: &[crate::refs::store::RefUpdate],
2973    err: Error,
2974) {
2975    let fail_ref = ref_store_error_refname(&err);
2976    let message = push_message_for_ref_store_error(&err);
2977    let batch_names: HashSet<&str> = batch.iter().map(|u| u.name.as_str()).collect();
2978    let in_batch = decisions
2979        .iter_mut()
2980        .filter(|d| d.apply && batch_names.contains(d.result.remote_ref.as_str()));
2981    if let Some(fail_name) = fail_ref {
2982        for d in in_batch {
2983            if d.result.remote_ref == fail_name {
2984                d.result.status = push_status_for_ref_store_error(&err);
2985                d.result.message = Some(message.clone());
2986            } else {
2987                d.result.status = PushRefStatus::AtomicPushFailed;
2988                d.result.message = Some("atomic push failed".to_owned());
2989            }
2990            d.apply = false;
2991        }
2992    } else {
2993        let mut first = true;
2994        for d in in_batch {
2995            if first {
2996                d.result.status = push_status_for_ref_store_error(&err);
2997                d.result.message = Some(message.clone());
2998                first = false;
2999            } else {
3000                d.result.status = PushRefStatus::AtomicPushFailed;
3001                d.result.message = Some("atomic push failed".to_owned());
3002            }
3003            d.apply = false;
3004        }
3005    }
3006}
3007
3008/// What a single accepted push update does once applied.
3009enum PushAction {
3010    /// Copy the closure of `src` to the remote and move the ref to `src`.
3011    Update(ObjectId),
3012    /// Delete the remote ref.
3013    Delete,
3014    /// No mutation (up-to-date or rejected).
3015    None,
3016}
3017
3018/// A decided-but-not-yet-applied push update.
3019struct PushDecision {
3020    result: PushRefResult,
3021    action: PushAction,
3022    /// Whether the second pass should apply `action`.
3023    apply: bool,
3024}
3025
3026impl PushDecision {
3027    /// The `<old> <new> <ref>` command a receive-pack would get for this update, or `None`
3028    /// when it changes nothing.
3029    fn ref_command(&self) -> Option<crate::serve::RefCommand> {
3030        let new = match self.action {
3031            PushAction::Update(src) => Some(src),
3032            PushAction::Delete => None,
3033            PushAction::None => return None,
3034        };
3035        Some(crate::serve::RefCommand {
3036            refname: self.result.remote_ref.clone(),
3037            old: self.result.old_oid,
3038            new,
3039        })
3040    }
3041
3042    /// Record that the remote refused this update for `reason`; nothing is applied.
3043    fn refuse(&mut self, reason: &crate::serve::RefUpdateRefusal) {
3044        self.result.status = PushRefStatus::RemoteRejected;
3045        self.result.message = Some(reason.to_string());
3046        self.apply = false;
3047        self.action = PushAction::None;
3048    }
3049}
3050
3051/// Decide the status of a single [`PushRefSpec`] without mutating either repo.
3052fn decide_push(
3053    spec: &PushRefSpec,
3054    local_odb: &Odb,
3055    remote_git_dir: &Path,
3056    local_repo: Option<&crate::repo::Repository>,
3057) -> Result<PushDecision> {
3058    let remote_current = crate::refs::resolve_ref(remote_git_dir, &spec.dst).ok();
3059
3060    // Up-to-date trumps every lease: pushing a non-delete to where the remote ref
3061    // already points is a no-op that succeeds even when the force-with-lease
3062    // expectation (a specific `expected_old` value, or `expect_absent`) does not
3063    // hold — "creating/moving a bookmark to the same place it already is is OK".
3064    // Must precede both the absence-lease and compare-and-swap checks below.
3065    if !spec.delete {
3066        if let Some(src) = spec.src {
3067            if remote_current == Some(src) {
3068                return Ok(PushDecision {
3069                    result: PushRefResult {
3070                        local_ref: push_spec_local_ref(spec),
3071                        remote_ref: spec.dst.clone(),
3072                        old_oid: remote_current,
3073                        new_oid: Some(src),
3074                        forced: false,
3075                        deletion: false,
3076                        status: PushRefStatus::UpToDate,
3077                        message: None,
3078                    },
3079                    action: PushAction::None,
3080                    apply: false,
3081                });
3082            }
3083        }
3084    }
3085
3086    // Absence lease (force-with-lease that the ref not exist): once the value is
3087    // actually changing (handled above), a destination that already exists fails
3088    // the lease and is rejected as stale.
3089    if spec.expect_absent && remote_current.is_some() {
3090        return Ok(PushDecision {
3091            result: PushRefResult {
3092                local_ref: push_spec_local_ref(spec),
3093                remote_ref: spec.dst.clone(),
3094                old_oid: remote_current,
3095                new_oid: spec.src,
3096                forced: false,
3097                deletion: spec.delete,
3098                status: PushRefStatus::RejectStale,
3099                message: Some("stale info".to_owned()),
3100            },
3101            action: PushAction::None,
3102            apply: false,
3103        });
3104    }
3105
3106    // Compare-and-swap (force-with-lease): the remote's current value must match
3107    // the caller's expectation, otherwise reject as stale. A `None` expectation
3108    // disables the value check.
3109    if let Some(expected) = spec.expected_old {
3110        if remote_current != Some(expected) {
3111            return Ok(PushDecision {
3112                result: PushRefResult {
3113                    local_ref: None,
3114                    remote_ref: spec.dst.clone(),
3115                    old_oid: remote_current,
3116                    new_oid: spec.src,
3117                    forced: false,
3118                    deletion: spec.delete,
3119                    status: PushRefStatus::RejectStale,
3120                    message: Some("stale info".to_owned()),
3121                },
3122                action: PushAction::None,
3123                apply: false,
3124            });
3125        }
3126    }
3127
3128    if spec.delete {
3129        let (status, action, apply, message) = match remote_current {
3130            Some(_) => (PushRefStatus::Ok, PushAction::Delete, true, None),
3131            None => (
3132                PushRefStatus::RejectRemoteRefMissing,
3133                PushAction::None,
3134                false,
3135                Some("remote ref does not exist".to_owned()),
3136            ),
3137        };
3138        return Ok(PushDecision {
3139            result: PushRefResult {
3140                local_ref: push_spec_local_ref(spec),
3141                remote_ref: spec.dst.clone(),
3142                old_oid: remote_current,
3143                new_oid: None,
3144                forced: false,
3145                deletion: true,
3146                status,
3147                message,
3148            },
3149            action,
3150            apply,
3151        });
3152    }
3153
3154    // Non-delete updates require a source object that exists locally.
3155    let Some(src) = spec.src else {
3156        return Err(Error::Message(format!(
3157            "push to '{}' has no source object and is not a deletion",
3158            spec.dst
3159        )));
3160    };
3161    if !local_odb.contains(&src)? {
3162        return Err(Error::Message(format!(
3163            "source object {src} for '{}' is missing from the local object store",
3164            spec.dst
3165        )));
3166    }
3167
3168    // Unchanged: the remote is already at the source.
3169    if remote_current == Some(src) {
3170        return Ok(PushDecision {
3171            result: PushRefResult {
3172                local_ref: push_spec_local_ref(spec),
3173                remote_ref: spec.dst.clone(),
3174                old_oid: remote_current,
3175                new_oid: Some(src),
3176                forced: false,
3177                deletion: false,
3178                status: PushRefStatus::UpToDate,
3179                message: None,
3180            },
3181            action: PushAction::None,
3182            apply: false,
3183        });
3184    }
3185
3186    // New ref: nothing on the remote yet — always allowed.
3187    let Some(old) = remote_current else {
3188        return Ok(PushDecision {
3189            result: PushRefResult {
3190                local_ref: push_spec_local_ref(spec),
3191                remote_ref: spec.dst.clone(),
3192                old_oid: None,
3193                new_oid: Some(src),
3194                forced: false,
3195                deletion: false,
3196                status: PushRefStatus::Ok,
3197                message: None,
3198            },
3199            action: PushAction::Update(src),
3200            apply: true,
3201        });
3202    };
3203
3204    if let Some(tag_decision) = existing_tag_push_decision(spec, &spec.dst, old, src) {
3205        return Ok(match tag_decision {
3206            ExistingTagPushDecision::Reject => PushDecision {
3207                result: PushRefResult {
3208                    local_ref: push_spec_local_ref(spec),
3209                    remote_ref: spec.dst.clone(),
3210                    old_oid: Some(old),
3211                    new_oid: Some(src),
3212                    forced: false,
3213                    deletion: false,
3214                    status: PushRefStatus::RejectAlreadyExists,
3215                    message: Some(PUSH_TAG_EXISTS_MESSAGE.to_owned()),
3216                },
3217                action: PushAction::None,
3218                apply: false,
3219            },
3220            ExistingTagPushDecision::ForcedUpdate => PushDecision {
3221                result: PushRefResult {
3222                    local_ref: push_spec_local_ref(spec),
3223                    remote_ref: spec.dst.clone(),
3224                    old_oid: Some(old),
3225                    new_oid: Some(src),
3226                    forced: true,
3227                    deletion: false,
3228                    status: PushRefStatus::Ok,
3229                    message: None,
3230                },
3231                action: PushAction::Update(src),
3232                apply: true,
3233            },
3234        });
3235    }
3236
3237    // Existing ref: fast-forward when the remote's current commit is an ancestor
3238    // of the source. Otherwise it is a non-fast-forward update, allowed only with
3239    // force (reported as forced).
3240    let is_ff = local_repo
3241        .map(|r| crate::merge_base::is_ancestor(r, old, src).unwrap_or(false))
3242        .unwrap_or(false);
3243
3244    if is_ff {
3245        Ok(PushDecision {
3246            result: PushRefResult {
3247                local_ref: push_spec_local_ref(spec),
3248                remote_ref: spec.dst.clone(),
3249                old_oid: Some(old),
3250                new_oid: Some(src),
3251                forced: false,
3252                deletion: false,
3253                status: PushRefStatus::Ok,
3254                message: None,
3255            },
3256            action: PushAction::Update(src),
3257            apply: true,
3258        })
3259    } else if spec.force {
3260        Ok(PushDecision {
3261            result: PushRefResult {
3262                local_ref: push_spec_local_ref(spec),
3263                remote_ref: spec.dst.clone(),
3264                old_oid: Some(old),
3265                new_oid: Some(src),
3266                forced: true,
3267                deletion: false,
3268                status: PushRefStatus::Ok,
3269                message: None,
3270            },
3271            action: PushAction::Update(src),
3272            apply: true,
3273        })
3274    } else {
3275        let status = push_reject_status_when_not_fast_forward(local_repo, old, src);
3276        Ok(PushDecision {
3277            result: PushRefResult {
3278                local_ref: push_spec_local_ref(spec),
3279                remote_ref: spec.dst.clone(),
3280                old_oid: Some(old),
3281                new_oid: Some(src),
3282                forced: false,
3283                deletion: false,
3284                status,
3285                message: Some("non-fast-forward".to_owned()),
3286            },
3287            action: PushAction::None,
3288            apply: false,
3289        })
3290    }
3291}
3292
3293/// When an update is not a fast-forward and force was not given, distinguish
3294/// "remote is strictly ahead" from a diverged history that needs force.
3295fn push_reject_status_when_not_fast_forward(
3296    local_repo: Option<&crate::repo::Repository>,
3297    old: crate::objects::ObjectId,
3298    src: crate::objects::ObjectId,
3299) -> PushRefStatus {
3300    let remote_ahead = local_repo
3301        .map(|r| crate::merge_base::is_ancestor(r, src, old).unwrap_or(false))
3302        .unwrap_or(false);
3303    if remote_ahead {
3304        PushRefStatus::RejectNonFastForward
3305    } else {
3306        PushRefStatus::RejectNeedsForce
3307    }
3308}
3309
3310/// A remote ref selected for fetch, with its computed local destination.
3311pub(crate) struct MatchedRef {
3312    pub(crate) remote_ref: String,
3313    /// Destination local tracking ref, or `None` for an empty (no-store) dst.
3314    pub(crate) local_ref: Option<String>,
3315    pub(crate) oid: ObjectId,
3316    pub(crate) force: bool,
3317    pub(crate) is_tag: bool,
3318    /// Added by [`TagMode::Following`] rather than selected by a refspec, so
3319    /// [`crate::fetch::retain_following_tags`] may drop it when its target is not
3320    /// in the fetched history.
3321    pub(crate) auto_followed: bool,
3322    /// Local destination is under `refs/tags/` (tag clobber rules apply).
3323    pub(crate) dest_is_tag: bool,
3324    /// Peeled target from ls-refs `peeled:` (annotated tags), when known.
3325    pub(crate) advertised_peel: Option<ObjectId>,
3326    /// How this ref appears in [`FETCH_HEAD`](https://git-scm.com/docs/git-fetch#_discussion).
3327    pub(crate) fetch_head: crate::fetch_request::FetchHeadPolicy,
3328}
3329
3330/// Open an [`Odb`] for a git directory, attaching the git dir so `hash_algo`
3331/// (and MIDX config) resolve correctly.
3332/// `objects/` of `git_dir`, which lives in the common directory for a linked worktree's
3333/// admin dir.
3334fn objects_dir(git_dir: &Path) -> PathBuf {
3335    crate::repo::common_git_dir_for_config(git_dir).join("objects")
3336}
3337
3338pub(crate) fn open_odb(git_dir: &Path) -> Odb {
3339    Odb::new(&objects_dir(git_dir)).with_config_git_dir(git_dir.to_path_buf())
3340}
3341
3342/// Infer the remote repository hash algorithm from fetch capability lines and ref oids.
3343pub(crate) fn remote_hash_algo_from_advertisement(
3344    capability_blocks: &[&[String]],
3345    remote_refs: &[(String, ObjectId)],
3346) -> HashAlgo {
3347    for caps in capability_blocks {
3348        for c in *caps {
3349            if let Some(name) = c.strip_prefix("object-format=") {
3350                if let Some(algo) = HashAlgo::from_name(name) {
3351                    return algo;
3352                }
3353            }
3354        }
3355    }
3356    remote_refs
3357        .iter()
3358        .find_map(|(_, oid)| HashAlgo::from_len(oid.as_bytes().len()))
3359        .unwrap_or(HashAlgo::Sha1)
3360}
3361
3362/// Before the first pack lands in a fresh clone, align `extensions.objectformat` with the remote.
3363///
3364/// Returns whether the local config was rewritten.
3365pub(crate) fn sync_empty_destination_object_format(
3366    local_git_dir: &Path,
3367    remote_algo: HashAlgo,
3368    opts: &FetchOptions,
3369) -> Result<bool> {
3370    if opts.dry_run || !opts.initial_remote_fetch {
3371        return Ok(false);
3372    }
3373    let local_odb = open_odb(local_git_dir);
3374    if local_odb.hash_algo() == remote_algo {
3375        return Ok(false);
3376    }
3377    if local_odb.approximate_object_count()? > 0 {
3378        return Err(Error::Message(format!(
3379            "unsupported object format: destination uses {} but remote uses {}; re-clone into an empty directory with a matching format",
3380            local_odb.hash_algo().name(),
3381            remote_algo.name()
3382        )));
3383    }
3384    write_empty_destination_object_format(local_git_dir, remote_algo)?;
3385    Ok(true)
3386}
3387
3388/// Reopen `local` after [`sync_empty_destination_object_format`] rewrote its object format, so
3389/// its object store uses the new hash algorithm.
3390pub(crate) fn reopen_after_format_change(
3391    local: &crate::repo::Repository,
3392) -> Result<crate::repo::Repository> {
3393    crate::repo::Repository::open(
3394        local.git_dir(),
3395        &RepositoryOptions::with_environment(local.environment().clone()),
3396    )
3397}
3398
3399fn write_empty_destination_object_format(git_dir: &Path, algo: HashAlgo) -> Result<()> {
3400    use crate::config::{ConfigFile, ConfigScope};
3401    let config_path = crate::repo::local_config_write_path(git_dir);
3402    let content = fs::read_to_string(&config_path).unwrap_or_default();
3403    let mut file = ConfigFile::parse(&config_path, &content, ConfigScope::Local)?;
3404    if algo == HashAlgo::Sha1 {
3405        file.unset("extensions.objectformat")?;
3406        file.set("core.repositoryformatversion", "0")?;
3407    } else {
3408        file.set("core.repositoryformatversion", "1")?;
3409        file.set("extensions.objectformat", algo.name())?;
3410    }
3411    file.write()?;
3412    Ok(())
3413}
3414
3415fn config_for_git_dir(git_dir: &Path) -> Option<crate::config::ConfigSet> {
3416    crate::config::ConfigSet::load(
3417        &crate::environment::Environment::empty(),
3418        Some(git_dir),
3419        true,
3420    )
3421    .ok()
3422}
3423
3424/// Write [`FETCH_HEAD`](https://git-scm.com/docs/git-fetch#_discussion) for anonymous URL/path fetches.
3425pub(crate) fn finish_anonymous_url_fetch(
3426    git_dir: &Path,
3427    opts: &FetchOptions,
3428    explicit_refspec_fetch: bool,
3429    default_branch: Option<&str>,
3430    remote_refs: &[(String, ObjectId)],
3431    matched: &[MatchedRef],
3432) -> Result<()> {
3433    if opts.dry_run {
3434        return Ok(());
3435    }
3436    let write_fetch_head = opts.remote_name.is_none() || explicit_refspec_fetch;
3437    if !write_fetch_head {
3438        return Ok(());
3439    }
3440    let source = opts.fetch_source.clone().or_else(|| {
3441        let name = opts.remote_name.as_deref()?;
3442        let config = config_for_git_dir(git_dir)?;
3443        config
3444            .get(&format!("remote.{name}.url"))
3445            .filter(|url| !url.trim().is_empty())
3446    });
3447    let Some(source) = source.as_deref() else {
3448        return Ok(());
3449    };
3450    let lines = fetch_head_lines_for_ad_hoc_fetch(
3451        matched,
3452        remote_refs,
3453        &opts.refspecs,
3454        default_branch,
3455        source,
3456    );
3457    if lines.is_empty() {
3458        return Ok(());
3459    }
3460    crate::fetch_head::write_fetch_head(git_dir, &lines)
3461}
3462
3463fn fetch_head_lines_for_ad_hoc_fetch(
3464    matched: &[MatchedRef],
3465    remote_refs: &[(String, ObjectId)],
3466    refspecs: &[String],
3467    default_branch: Option<&str>,
3468    source: &str,
3469) -> Vec<crate::fetch_head::FetchHeadLine> {
3470    use crate::fetch_head::FetchHeadLine;
3471    use crate::remote::Remote;
3472
3473    let anonymous_only = refspecs.len() == 1
3474        && refspecs[0].trim_start_matches('+') == Remote::ANONYMOUS_FETCH_REFSPEC;
3475
3476    if anonymous_only {
3477        if let Some(branch) = default_branch {
3478            let head_ref = format!("refs/heads/{branch}");
3479            if let Some(m) = matched
3480                .iter()
3481                .find(|m| m.remote_ref == head_ref && !m.is_tag)
3482            {
3483                return vec![FetchHeadLine {
3484                    oid: m.oid,
3485                    for_merge: true,
3486                    description: source.to_owned(),
3487                }];
3488            }
3489        }
3490        if let Some((_, oid)) = remote_refs.iter().find(|(name, _)| name == "HEAD") {
3491            return vec![FetchHeadLine {
3492                oid: *oid,
3493                for_merge: true,
3494                description: source.to_owned(),
3495            }];
3496        }
3497        return Vec::new();
3498    }
3499
3500    matched
3501        .iter()
3502        .filter(|m| m.fetch_head == crate::fetch_request::FetchHeadPolicy::ForMerge)
3503        .map(|m| FetchHeadLine {
3504            oid: m.oid,
3505            for_merge: true,
3506            description: crate::fetch_request::fetch_head_description(
3507                &m.remote_ref,
3508                m.is_tag,
3509                source,
3510            ),
3511        })
3512        .collect()
3513}
3514
3515/// Tip oid of `HEAD` from a ref advertisement (before `HEAD` is stripped).
3516#[must_use]
3517pub(crate) fn remote_head_oid_from_advertised(
3518    advertised: &[(String, ObjectId)],
3519) -> Option<ObjectId> {
3520    advertised
3521        .iter()
3522        .find(|(name, _)| name == "HEAD")
3523        .map(|(_, oid)| *oid)
3524}
3525
3526/// Match a ref name against the positive refspecs, returning the destination
3527/// local ref name (`Some(name)`), `None`+stored=false collapsed: returns
3528/// `Some(Some(dst))` to store, `Some(None)` to fetch-without-store, or `None`
3529/// when no positive refspec matches.
3530pub(crate) fn match_positive(refname: &str, positive: &[RefspecItem]) -> Option<Option<String>> {
3531    for item in positive {
3532        let Some(src) = item.src.as_deref() else {
3533            continue;
3534        };
3535        if let Some(dst) = apply_refspec(src, item.dst.as_deref(), refname) {
3536            // Empty dst means "fetch but do not store".
3537            if dst.is_empty() {
3538                return Some(None);
3539            }
3540            return Some(Some(dst));
3541        }
3542    }
3543    None
3544}
3545
3546/// Whether any positive refspec matching `refname` requested force (`+`).
3547pub(crate) fn refspecs_force(refname: &str, positive: &[RefspecItem]) -> bool {
3548    positive.iter().any(|item| {
3549        item.force
3550            && item
3551                .src
3552                .as_deref()
3553                .is_some_and(|src| apply_refspec(src, item.dst.as_deref(), refname).is_some())
3554    })
3555}
3556
3557/// Whether `refname` is excluded by any negative refspec.
3558pub(crate) fn ref_excluded(refname: &str, negatives: &[RefspecItem]) -> bool {
3559    negatives.iter().any(|item| {
3560        item.src
3561            .as_deref()
3562            .is_some_and(|src| glob_matches(src, refname))
3563    })
3564}
3565
3566/// Apply a `<src>[:<dst>]` refspec to `refname`, returning the destination ref.
3567///
3568/// Supports a single `*` wildcard (Git's fetch refspec form). When `dst` is
3569/// `None` the destination equals the matched source (rare for tracking fetches);
3570/// when `dst` is `Some("")` the empty string is returned (fetch-without-store).
3571pub(crate) fn apply_refspec(src: &str, dst: Option<&str>, refname: &str) -> Option<String> {
3572    match src.find('*') {
3573        Some(star) => {
3574            let prefix = &src[..star];
3575            let suffix = &src[star + 1..];
3576            if !refname.starts_with(prefix)
3577                || !refname.ends_with(suffix)
3578                || refname.len() < prefix.len() + suffix.len()
3579            {
3580                return None;
3581            }
3582            let middle = &refname[prefix.len()..refname.len() - suffix.len()];
3583            match dst {
3584                None => Some(refname.to_owned()),
3585                Some("") => Some(String::new()),
3586                Some(d) => Some(d.replacen('*', middle, 1)),
3587            }
3588        }
3589        None => {
3590            if src != refname {
3591                return None;
3592            }
3593            match dst {
3594                None => Some(refname.to_owned()),
3595                Some("") => Some(String::new()),
3596                Some(d) => Some(d.to_owned()),
3597            }
3598        }
3599    }
3600}
3601
3602/// Whether `pattern` (a refspec src side, possibly with one `*`) matches `refname`.
3603fn glob_matches(pattern: &str, refname: &str) -> bool {
3604    match pattern.find('*') {
3605        Some(star) => {
3606            let prefix = &pattern[..star];
3607            let suffix = &pattern[star + 1..];
3608            refname.starts_with(prefix)
3609                && refname.ends_with(suffix)
3610                && refname.len() >= prefix.len() + suffix.len()
3611        }
3612        None => pattern == refname,
3613    }
3614}
3615
3616/// Add tags to the matched set according to [`TagMode`].
3617///
3618/// Under `Following`, tags named in `already_local` (see
3619/// [`crate::fetch::tags_already_local`]) are skipped, matching Git's auto-follow,
3620/// which never reconsiders a tag the local repository already has by name.
3621#[allow(clippy::too_many_arguments)]
3622pub(crate) fn apply_tag_mode(
3623    mode: TagMode,
3624    remote_refs: &[(String, ObjectId)],
3625    remote_odb: &Odb,
3626    shallow_boundaries: &HashSet<ObjectId>,
3627    negatives: &[RefspecItem],
3628    already_local: &HashSet<String>,
3629    force_all_tags: bool,
3630    matched: &mut Vec<MatchedRef>,
3631    matched_oids: &mut HashSet<ObjectId>,
3632    seen_remote_ref: &mut HashSet<String>,
3633) -> Result<HashSet<ObjectId>> {
3634    let mut following_only = HashSet::new();
3635    if mode == TagMode::None {
3636        return Ok(following_only);
3637    }
3638
3639    // Tags this call may add, with the object each one peels to (`None` when
3640    // the tag cannot be peeled; only the tag oid itself can then match).
3641    let candidates: Vec<(&String, ObjectId, Option<ObjectId>)> = remote_refs
3642        .iter()
3643        .filter(|(name, _)| {
3644            name.starts_with("refs/tags/")
3645                && !seen_remote_ref.contains(name)
3646                && !ref_excluded(name, negatives)
3647                && !(mode == TagMode::Following && already_local.contains(name))
3648        })
3649        .map(|(name, oid)| {
3650            let peeled = match mode {
3651                TagMode::Following => remote_odb.peel_tags(*oid).ok().map(|(p, _)| p),
3652                _ => None,
3653            };
3654            (name, *oid, peeled)
3655        })
3656        .collect();
3657
3658    // For Following, decide reachability on the source ODB using the effective
3659    // shallow grafts for this fetch. Post-pack pruning via
3660    // [`crate::fetch::retain_following_tags`] still drops tags whose objects did
3661    // not arrive. Using the local ODB here would miss every tip on first fetch.
3662    let following_closure: HashSet<ObjectId> = if mode == TagMode::Following {
3663        let roots: Vec<ObjectId> = matched
3664            .iter()
3665            .filter(|m| !m.is_tag)
3666            .map(|m| m.oid)
3667            .collect();
3668        let targets: HashSet<ObjectId> = candidates
3669            .iter()
3670            .map(|(_, oid, peeled)| peeled.unwrap_or(*oid))
3671            .collect();
3672        crate::fetch::reachable_commits(remote_odb, &roots, &targets, shallow_boundaries)?
3673    } else {
3674        HashSet::new()
3675    };
3676
3677    for (name, oid, peeled) in candidates {
3678        let keep = match mode {
3679            TagMode::All => true,
3680            // Keep when the tag (or what it peels to) is in the fetched closure.
3681            TagMode::Following => {
3682                following_closure.contains(&oid)
3683                    || peeled.is_some_and(|p| following_closure.contains(&p))
3684            }
3685            TagMode::None => false,
3686        };
3687        if keep && seen_remote_ref.insert(name.clone()) {
3688            if matched_oids.insert(oid) && mode == TagMode::Following {
3689                following_only.insert(oid);
3690            }
3691            matched.push(MatchedRef {
3692                remote_ref: name.clone(),
3693                local_ref: Some(name.clone()),
3694                oid,
3695                force: force_all_tags && mode == TagMode::All,
3696                is_tag: true,
3697                auto_followed: mode == TagMode::Following,
3698                dest_is_tag: true,
3699                advertised_peel: peeled,
3700                fetch_head: crate::fetch_request::FetchHeadPolicy::ForMerge,
3701            });
3702        }
3703    }
3704    Ok(following_only)
3705}
3706
3707/// Commit tip to use when computing shallow boundaries for one matched ref.
3708fn shallow_want_commit_tip(remote_odb: &Odb, matched: &MatchedRef) -> Result<ObjectId> {
3709    if !matched.is_tag {
3710        return Ok(matched.oid);
3711    }
3712    if let Some(peel) = matched.advertised_peel.filter(|p| !p.is_zero()) {
3713        return Ok(peel);
3714    }
3715    if remote_odb.contains(&matched.oid)? {
3716        let (peeled, _) = remote_odb.peel_tags(matched.oid)?;
3717        return Ok(peeled);
3718    }
3719    Ok(matched.oid)
3720}
3721
3722/// Whether a tag ref's peeled target (or the oid itself for lightweight tags)
3723/// exists in the local or remote object database.
3724fn ref_target_exists(local_odb: &Odb, remote_odb: &Odb, tag_oid: ObjectId) -> Result<bool> {
3725    let peel_odb = if local_odb.contains(&tag_oid)? {
3726        local_odb
3727    } else {
3728        remote_odb
3729    };
3730    match peel_odb.peel_tags(tag_oid) {
3731        Ok((peeled, _)) => local_odb.contains(&peeled),
3732        Err(_) => Ok(false),
3733    }
3734}
3735
3736/// Tag refs whose peeled targets (and annotated tag objects) exist locally after
3737/// a local fetch.
3738///
3739/// Objects just copied are checked via `copied` without store probes. Custom
3740/// primaries batch any remaining ids in one [`Odb::missing_materialized_many`]
3741/// call instead of per-tag [`ref_target_exists`] scalar `contains` probes.
3742fn tag_refs_with_local_targets(
3743    local_odb: &Odb,
3744    remote_odb: &Odb,
3745    tag_oids: impl IntoIterator<Item = ObjectId>,
3746    copied: &HashSet<ObjectId>,
3747) -> Result<HashSet<ObjectId>> {
3748    let mut ok = HashSet::new();
3749    let mut pending: Vec<(ObjectId, ObjectId)> = Vec::new();
3750
3751    for tag_oid in tag_oids {
3752        let Ok((peeled, _)) = remote_odb.peel_tags(tag_oid) else {
3753            continue;
3754        };
3755        let peeled_copied = copied.contains(&peeled);
3756        let tag_copied = tag_oid == peeled || copied.contains(&tag_oid);
3757        if peeled_copied && tag_copied {
3758            ok.insert(tag_oid);
3759            continue;
3760        }
3761        if local_odb.uses_files_primary() {
3762            if ref_target_exists(local_odb, remote_odb, tag_oid)? {
3763                ok.insert(tag_oid);
3764            }
3765            continue;
3766        }
3767        pending.push((tag_oid, peeled));
3768    }
3769
3770    if pending.is_empty() {
3771        return Ok(ok);
3772    }
3773
3774    let mut probe_ids = Vec::new();
3775    for (tag_oid, peeled) in &pending {
3776        if !copied.contains(peeled) {
3777            probe_ids.push(*peeled);
3778        }
3779        if *tag_oid != *peeled && !copied.contains(tag_oid) {
3780            probe_ids.push(*tag_oid);
3781        }
3782    }
3783    probe_ids.sort_unstable();
3784    probe_ids.dedup();
3785    let missing: HashSet<ObjectId> = if probe_ids.is_empty() {
3786        HashSet::new()
3787    } else {
3788        local_odb
3789            .missing_materialized_many(&probe_ids)?
3790            .into_iter()
3791            .collect()
3792    };
3793    for (tag_oid, peeled) in pending {
3794        let peeled_ok = copied.contains(&peeled) || !missing.contains(&peeled);
3795        let tag_ok = tag_oid == peeled || copied.contains(&tag_oid) || !missing.contains(&tag_oid);
3796        if peeled_ok && tag_ok {
3797            ok.insert(tag_oid);
3798        }
3799    }
3800    Ok(ok)
3801}
3802
3803/// Message on [`PushRefStatus::RejectAlreadyExists`] when an existing remote tag
3804/// would change without `--force`. Distinct from the create race message `"already exists"`.
3805pub(crate) const PUSH_TAG_EXISTS_MESSAGE: &str = "tag already exists";
3806
3807/// Outcome for updating a remote tag that already exists (Git never fast-forwards tags).
3808pub(crate) enum ExistingTagPushDecision {
3809    Reject,
3810    ForcedUpdate,
3811}
3812
3813/// When `spec` updates `refs/tags/*` and the remote already has a different oid, Git rejects
3814/// unless `spec.force` is set (even when peeled commits fast-forward).
3815pub(crate) fn existing_tag_push_decision(
3816    spec: &PushRefSpec,
3817    dst: &str,
3818    old: ObjectId,
3819    src: ObjectId,
3820) -> Option<ExistingTagPushDecision> {
3821    if spec.delete || !dst.starts_with("refs/tags/") || old == src {
3822        return None;
3823    }
3824    Some(if spec.force {
3825        ExistingTagPushDecision::ForcedUpdate
3826    } else {
3827        ExistingTagPushDecision::Reject
3828    })
3829}
3830
3831/// Classify a single ref update into an [`UpdateMode`].
3832///
3833/// When `classify_odb` is set (network fetch `--dry-run` after quarantined pack
3834/// ingest), ancestry uses that store with its configured alternates instead of
3835/// opening a [`crate::repo::Repository`].
3836pub(crate) fn classify_update(
3837    old: Option<&ObjectId>,
3838    new: &ObjectId,
3839    force: bool,
3840    is_tag: bool,
3841    repo: Option<&crate::repo::Repository>,
3842    classify_odb: Option<&crate::odb::Odb>,
3843) -> UpdateMode {
3844    let Some(old) = old else {
3845        return UpdateMode::New;
3846    };
3847    if old == new {
3848        return UpdateMode::UpToDate;
3849    }
3850    // Fast-forward when old is an ancestor of new (commit history only).
3851    let ff = if let Some(r) = repo {
3852        crate::merge_base::is_ancestor(r, *old, *new).unwrap_or(false)
3853    } else if let Some(odb) = classify_odb {
3854        crate::merge_base::is_ancestor_odb_walk(odb, *old, *new).unwrap_or(false)
3855    } else {
3856        false
3857    };
3858    if ff && !is_tag {
3859        return UpdateMode::FastForward;
3860    }
3861    if force {
3862        return UpdateMode::Forced;
3863    }
3864    if is_tag {
3865        return UpdateMode::TagUpdateRejected;
3866    }
3867    UpdateMode::NonFastForwardRejected
3868}
3869
3870/// Default-branch symref for a wildcard refspec destination (`<prefix>HEAD`).
3871///
3872/// Remote names may contain `/` (e.g. `team/origin` → `refs/remotes/team/origin/HEAD`).
3873fn is_default_head_under_tracking_prefix(refname: &str, tracking_prefix: &str) -> bool {
3874    refname == format!("{tracking_prefix}HEAD")
3875}
3876
3877/// Local tracking refs that would be removed by `git fetch --prune` given the current remote.
3878///
3879/// Returns full ref names and their current object ids. The default-branch symref
3880/// `refs/remotes/<remote>/HEAD` is never included.
3881///
3882/// # Errors
3883///
3884/// Propagates ref listing failures.
3885pub fn tracking_refs_gone_on_remote(
3886    local_git_dir: &Path,
3887    positive: &[RefspecItem],
3888    remote_refs: &[(String, ObjectId)],
3889) -> Result<Vec<(String, ObjectId)>> {
3890    tracking_refs_gone_with(
3891        positive,
3892        remote_refs,
3893        |prefix| crate::refs::list_refs(local_git_dir, prefix),
3894        |name| crate::refs::resolve_ref(local_git_dir, name),
3895    )
3896}
3897
3898/// [`tracking_refs_gone_on_remote`] reading the local tracking refs through `local`'s ref
3899/// store, so an injected [`crate::refs::store::RefStore`] is honoured.
3900///
3901/// # Errors
3902///
3903/// Propagates ref listing failures.
3904pub(crate) fn tracking_refs_gone_in(
3905    local: &crate::repo::Repository,
3906    positive: &[RefspecItem],
3907    remote_refs: &[(String, ObjectId)],
3908) -> Result<Vec<(String, ObjectId)>> {
3909    tracking_refs_gone_with(
3910        positive,
3911        remote_refs,
3912        |prefix| crate::refs::list_refs_in(local, prefix),
3913        |name| crate::refs::resolve_ref_in(local, name),
3914    )
3915}
3916
3917fn tracking_refs_gone_with(
3918    positive: &[RefspecItem],
3919    remote_refs: &[(String, ObjectId)],
3920    list: impl Fn(&str) -> Result<Vec<(String, ObjectId)>>,
3921    resolve: impl Fn(&str) -> Result<ObjectId>,
3922) -> Result<Vec<(String, ObjectId)>> {
3923    let mut live: HashSet<String> = HashSet::new();
3924    for (name, _) in remote_refs {
3925        if let Some(Some(dst)) = match_positive(name, positive) {
3926            live.insert(dst);
3927        }
3928    }
3929
3930    let mut pruned: HashMap<String, ObjectId> = HashMap::new();
3931    for item in positive {
3932        let Some(dst) = item.dst.as_deref() else {
3933            continue;
3934        };
3935        if let Some(star) = dst.find('*') {
3936            let prefix = &dst[..star];
3937            for (name, oid) in list(prefix)? {
3938                if !name.starts_with(prefix) {
3939                    continue;
3940                }
3941                if is_default_head_under_tracking_prefix(&name, prefix) {
3942                    continue;
3943                }
3944                if !live.contains(&name) {
3945                    pruned.entry(name).or_insert(oid);
3946                }
3947            }
3948        } else if !live.contains(dst) {
3949            let exact_prefix = dst.strip_suffix("HEAD").filter(|p| p.ends_with('/'));
3950            if exact_prefix.is_some_and(|p| is_default_head_under_tracking_prefix(dst, p)) {
3951                continue;
3952            }
3953            if let Ok(oid) = resolve(dst) {
3954                pruned.entry(dst.to_owned()).or_insert(oid);
3955            }
3956        }
3957    }
3958    Ok(pruned.into_iter().collect())
3959}
3960
3961/// Optional prune hint when a fetch ref write is rejected.
3962pub(crate) struct FetchRefRejectHint<'a> {
3963    /// Whether this fetch runs with `--prune` (no stale-ref hints when true).
3964    pub prune: bool,
3965    /// Positive refspecs for the fetch (stale tracking detection).
3966    pub positive: &'a [RefspecItem],
3967    /// Advertised remote refs at fetch time.
3968    pub remote_refs: &'a [(String, ObjectId)],
3969    /// Remote nickname (`origin`, …) for hint text.
3970    pub remote_name: Option<&'a str>,
3971}
3972
3973fn fetch_ref_store_error_rejectable(err: &Error) -> bool {
3974    matches!(
3975        err,
3976        Error::RefStore(crate::refs::store::RefStoreError::NameUnavailable { .. })
3977            | Error::RefStore(crate::refs::store::RefStoreError::LockHeld { .. })
3978    )
3979}
3980
3981fn blocking_ref_from_unavailable(reason: &crate::refs::RefnameUnavailable) -> Option<&str> {
3982    match reason {
3983        crate::refs::RefnameUnavailable::AncestorExists { blocking, .. }
3984        | crate::refs::RefnameUnavailable::DescendantExists { blocking, .. } => Some(blocking),
3985        crate::refs::RefnameUnavailable::SameBatch { .. } => None,
3986    }
3987}
3988
3989fn append_fetch_prune_hint(
3990    note: &mut String,
3991    stale_tracking: &HashSet<String>,
3992    blocking: &str,
3993    remote_name: Option<&str>,
3994) {
3995    if stale_tracking.contains(blocking) {
3996        let remote = remote_name.unwrap_or("origin");
3997        note.push_str(&format!(
3998            " — stale remote-tracking ref; try `grit fetch --prune` or `grit remote prune {remote}`"
3999        ));
4000    }
4001}
4002
4003fn mark_fetch_ref_update_rejected(
4004    updates: &mut [RefUpdate],
4005    local_ref: &str,
4006    err: &Error,
4007    stale_tracking: &HashSet<String>,
4008    hint: &FetchRefRejectHint<'_>,
4009) {
4010    let Some(update) = updates
4011        .iter_mut()
4012        .find(|u| u.local_ref.as_deref() == Some(local_ref))
4013    else {
4014        return;
4015    };
4016    update.mode = UpdateMode::RefLockRejected;
4017    let mut note = match err {
4018        Error::RefStore(crate::refs::store::RefStoreError::NameUnavailable { reason }) => {
4019            let mut text = reason.lock_message_suffix();
4020            if let Some(blocking) = blocking_ref_from_unavailable(reason) {
4021                append_fetch_prune_hint(&mut text, stale_tracking, blocking, hint.remote_name);
4022            }
4023            text
4024        }
4025        Error::RefStore(crate::refs::store::RefStoreError::LockHeld { name }) => {
4026            format!("ref '{name}' is locked")
4027        }
4028        _ => err.to_string(),
4029    };
4030    if note.is_empty() {
4031        note = "unable to update local ref".to_owned();
4032    }
4033    update.note = Some(note);
4034}
4035
4036/// Commit tracking-ref writes from a fetch into `local`'s ref store, applying every ref
4037/// that can be updated.
4038///
4039/// Each of `writes` stores a value under a name whatever the ref held before. Non-initial
4040/// fetches attach a reflog line when [`crate::refs::on_store::reflog_wanted`] would
4041/// (`core.logAllRefUpdates`, or the log already exists). Initial clone fetches omit
4042/// branch reflogs (packed import) except the clone line on `refs/remotes/{remote}/HEAD`
4043/// written by [`crate::fetch::finish_initial_remote_fetch_layout`].
4044///
4045/// Updates are committed as one batch transaction when the full batch validates;
4046/// otherwise one at a time, recording lock or namespace failures on the matching
4047/// [`RefUpdate`] rows instead of aborting the whole fetch.
4048///
4049/// With `initial` (the first fetch of a clone), the direct refs are committed first as an
4050/// initial transaction ([`crate::refs::store::RefTransaction::initial`]), which the files
4051/// backend stores in one `packed-refs` write; symbolic refs follow as a regular batch.
4052///
4053/// # Errors
4054///
4055/// Propagates non-rejectable ref-store and I/O errors from the fallback path.
4056pub(crate) fn apply_fetch_ref_updates(
4057    local: &crate::repo::Repository,
4058    writes: Vec<(String, crate::refs::store::RawRef)>,
4059    updates: &mut [RefUpdate],
4060    hint: FetchRefRejectHint<'_>,
4061    initial: bool,
4062    fetch_reflog: Option<&FetchApplyReflog<'_>>,
4063) -> Result<()> {
4064    use crate::refs::store::{RawRef, ReflogUpdate};
4065    use std::collections::HashMap;
4066
4067    let store = local.refs();
4068    let config = local.config()?;
4069    let null_oid = store.reference_transaction_null_oid();
4070    let mode_by_local: HashMap<String, (UpdateMode, Option<ObjectId>, Option<ObjectId>)> = updates
4071        .iter()
4072        .filter_map(|u| {
4073            let local_ref = u.local_ref.as_ref()?;
4074            Some((local_ref.clone(), (u.mode, u.old_oid, u.new_oid)))
4075        })
4076        .collect();
4077
4078    let store_update = |(name, value): &(String, RawRef)| -> Result<crate::refs::store::RefUpdate> {
4079        let reflog = if initial {
4080            None
4081        } else if let Some(ctx) = fetch_reflog {
4082            let Some((mode, old_oid, new_oid)) = mode_by_local.get(name).copied() else {
4083                return Ok(crate::refs::store::RefUpdate {
4084                    name: name.clone(),
4085                    new_value: Some(value.clone()),
4086                    expected: crate::refs::store::Expected::Any,
4087                    reflog: None,
4088                    flags: Default::default(),
4089                });
4090            };
4091            let Some(reason) = mode.fetch_reflog_reason() else {
4092                return Ok(crate::refs::store::RefUpdate {
4093                    name: name.clone(),
4094                    new_value: Some(value.clone()),
4095                    expected: crate::refs::store::Expected::Any,
4096                    reflog: None,
4097                    flags: Default::default(),
4098                });
4099            };
4100            if !crate::refs::reflog_wanted(store, &config, name)? {
4101                return Ok(crate::refs::store::RefUpdate {
4102                    name: name.clone(),
4103                    new_value: Some(value.clone()),
4104                    expected: crate::refs::store::Expected::Any,
4105                    reflog: None,
4106                    flags: Default::default(),
4107                });
4108            }
4109            let old = old_oid.unwrap_or(null_oid);
4110            let new = match value {
4111                RawRef::Direct(oid) => *oid,
4112                RawRef::Symbolic(_) => new_oid.unwrap_or(null_oid),
4113            };
4114            let message = format!("{}: {reason}", ctx.action);
4115            Some(ReflogUpdate {
4116                identity: ctx.identity.to_owned(),
4117                message,
4118                time: time::OffsetDateTime::UNIX_EPOCH,
4119                old_oid: Some(old),
4120                new_oid: Some(new),
4121            })
4122        } else {
4123            None
4124        };
4125        Ok(crate::refs::store::RefUpdate {
4126            name: name.clone(),
4127            new_value: Some(value.clone()),
4128            expected: crate::refs::store::Expected::Any,
4129            reflog,
4130            flags: Default::default(),
4131        })
4132    };
4133
4134    let mut pending: Vec<&(String, RawRef)> = writes.iter().collect();
4135    if initial {
4136        let (direct, rest): (Vec<_>, Vec<_>) = pending
4137            .iter()
4138            .copied()
4139            .partition(|(_, value)| matches!(value, RawRef::Direct(_)));
4140        let direct = direct
4141            .into_iter()
4142            .map(&store_update)
4143            .collect::<Result<Vec<_>>>()?;
4144        if crate::refs::commit_initial_ref_updates_on_store(store, direct).is_ok() {
4145            pending = rest;
4146        }
4147    }
4148    let batch = pending
4149        .iter()
4150        .copied()
4151        .map(&store_update)
4152        .collect::<Result<Vec<_>>>()?;
4153    if pending.is_empty() || crate::refs::commit_owned_ref_updates_on_store(store, batch).is_ok() {
4154        return Ok(());
4155    }
4156
4157    let stale_tracking: HashSet<String> = if hint.prune {
4158        HashSet::new()
4159    } else {
4160        tracking_refs_gone_in(local, hint.positive, hint.remote_refs)?
4161            .into_iter()
4162            .map(|(name, _)| name)
4163            .collect()
4164    };
4165
4166    for write in pending {
4167        match crate::refs::commit_ref_update_on_store(store, store_update(write)?) {
4168            Ok(()) => {}
4169            Err(err) if fetch_ref_store_error_rejectable(&err) => {
4170                mark_fetch_ref_update_rejected(updates, &write.0, &err, &stale_tracking, &hint);
4171            }
4172            Err(err) => return Err(err),
4173        }
4174    }
4175    Ok(())
4176}
4177
4178/// Delete local tracking refs whose remote counterpart no longer exists.
4179///
4180/// A local tracking ref is a prune candidate when it lives under the destination
4181/// namespace of some positive wildcard refspec and no current remote ref maps to
4182/// it. Matches `git fetch --prune` for the common `refs/remotes/<remote>/*` case.
4183///
4184/// Candidates are read from, and (unless `dry_run`) deleted in one batch through, `local`'s
4185/// ref store. Each pruned ref is reported in `updates`.
4186///
4187/// # Errors
4188///
4189/// Propagates ref listing and ref-store failures.
4190pub(crate) fn prune_tracking_refs(
4191    local: &crate::repo::Repository,
4192    positive: &[RefspecItem],
4193    remote_refs: &[(String, ObjectId)],
4194    dry_run: bool,
4195    updates: &mut Vec<RefUpdate>,
4196) -> Result<()> {
4197    let pruned: Vec<(String, ObjectId)> = tracking_refs_gone_in(local, positive, remote_refs)?;
4198
4199    let prune_delete_flags = crate::refs::store::RefUpdateFlags {
4200        no_deref: true,
4201        ..Default::default()
4202    };
4203    let mut batch: Vec<crate::refs::store::RefUpdate> = Vec::new();
4204    let mut pruned_names: Vec<String> = Vec::new();
4205    for (name, oid) in pruned.into_iter() {
4206        pruned_names.push(name.clone());
4207        if !dry_run {
4208            batch.push(crate::refs::store::RefUpdate {
4209                name: name.clone(),
4210                new_value: None,
4211                expected: crate::refs::store::Expected::Any,
4212                reflog: None,
4213                flags: prune_delete_flags,
4214            });
4215        }
4216        updates.push(RefUpdate {
4217            remote_ref: String::new(),
4218            local_ref: Some(name),
4219            old_oid: Some(oid),
4220            new_oid: None,
4221            mode: UpdateMode::DeletedMissing,
4222            note: Some("pruned (gone on remote)".to_owned()),
4223        });
4224    }
4225    let store = local.refs();
4226    crate::refs::commit_owned_ref_updates_on_store(store, batch)?;
4227    if !dry_run {
4228        for name in pruned_names {
4229            store.delete_reflog(&name)?;
4230        }
4231    }
4232    Ok(())
4233}
4234
4235#[cfg(test)]
4236mod tests {
4237    use super::*;
4238    use crate::progress::ProgressPhase;
4239    use crate::ref_storage::RefStorageFormat;
4240    use crate::repo::init_repository;
4241    use std::process::Command;
4242
4243    #[derive(Debug, Default)]
4244    struct PhaseRecorder {
4245        events: Vec<&'static str>,
4246        started: Vec<(ProgressPhase, Option<u64>)>,
4247        finished: Vec<ProgressPhase>,
4248        counts: Vec<u64>,
4249        enumerating_counts: Vec<u64>,
4250        copying_counts: Vec<u64>,
4251        active: Option<ProgressPhase>,
4252        current: u64,
4253        received: Vec<ReceivedObjects>,
4254        want_received: bool,
4255    }
4256
4257    impl ProgressSink for PhaseRecorder {
4258        fn start(&mut self, phase: ProgressPhase, total: Option<u64>) {
4259            self.events.push("start");
4260            self.started.push((phase, total));
4261            self.active = Some(phase);
4262            self.current = 0;
4263        }
4264        fn inc(&mut self, units: u64) {
4265            self.events.push("inc");
4266            self.current += units;
4267            self.record_count();
4268        }
4269        fn set(&mut self, current: u64) {
4270            self.events.push("set");
4271            self.current = current;
4272            self.record_count();
4273        }
4274        fn finish(&mut self) {
4275            self.events.push("finish");
4276            if let Some(phase) = self.active.take() {
4277                self.finished.push(phase);
4278            }
4279        }
4280    }
4281
4282    impl PhaseRecorder {
4283        fn record_count(&mut self) {
4284            self.counts.push(self.current);
4285            match self.active {
4286                Some(ProgressPhase::EnumeratingObjects) => {
4287                    self.enumerating_counts.push(self.current);
4288                }
4289                Some(ProgressPhase::CopyingObjects) => {
4290                    self.copying_counts.push(self.current);
4291                }
4292                _ => {}
4293            }
4294        }
4295    }
4296
4297    impl Progress for PhaseRecorder {
4298        fn wants_received_objects(&self) -> bool {
4299            self.want_received
4300        }
4301
4302        fn received_objects(&mut self, counts: ReceivedObjects) {
4303            assert_eq!(
4304                self.active,
4305                Some(ProgressPhase::CopyingObjects),
4306                "received_objects must run only during CopyingObjects (active={active:?}, counts={counts:?})",
4307                active = self.active,
4308            );
4309            assert!(
4310                counts.received <= counts.total,
4311                "received must not exceed total: {counts:?}"
4312            );
4313            self.received.push(counts);
4314        }
4315    }
4316
4317    fn git_ok(dir: &Path, args: &[&str]) -> bool {
4318        Command::new("git") // hygiene: git fixture comparison in cfg(test) module
4319            .env("GIT_AUTHOR_NAME", "Test")
4320            .env("GIT_AUTHOR_EMAIL", "test@example.com")
4321            .env("GIT_COMMITTER_NAME", "Test")
4322            .env("GIT_COMMITTER_EMAIL", "test@example.com")
4323            .current_dir(dir)
4324            .args(args)
4325            .env("GIT_CONFIG_GLOBAL", "/dev/null")
4326            .env("GIT_CONFIG_SYSTEM", "/dev/null")
4327            .status()
4328            .map(|s| s.success())
4329            .unwrap_or(false)
4330    }
4331
4332    /// Where in [`push_local_from`] a test hook interleaves another writer.
4333    #[derive(Clone, Copy, PartialEq, Eq, Debug)]
4334    pub(crate) enum PushHookPoint {
4335        /// After every update is decided and its objects copied, before the remote's
4336        /// `receive.*` policy rechecks each update against the remote refs.
4337        BeforeReceivePolicy,
4338        /// After the policy check, before any remote ref is written.
4339        BeforeRefCommit,
4340    }
4341
4342    type PushHook = (PushHookPoint, Box<dyn FnOnce()>);
4343
4344    // hygiene: test-only push interleaving hook
4345    thread_local! {
4346        static PUSH_HOOK: std::cell::RefCell<Option<PushHook>> =
4347            const { std::cell::RefCell::new(None) };
4348    }
4349
4350    /// Runs (once) the hook installed by [`on_next_push`] when the push reaches `point`.
4351    pub(super) fn run_push_hook(point: PushHookPoint) {
4352        let hook = PUSH_HOOK.with(|slot| {
4353            let mut slot = slot.borrow_mut();
4354            match slot.as_ref() {
4355                Some((at, _)) if *at == point => slot.take().map(|(_, hook)| hook),
4356                _ => None,
4357            }
4358        });
4359        if let Some(hook) = hook {
4360            hook();
4361        }
4362    }
4363
4364    /// Run `hook` inside the next local push on this thread when it reaches `point`.
4365    pub(crate) fn on_next_push(point: PushHookPoint, hook: impl FnOnce() + 'static) {
4366        PUSH_HOOK.with(|slot| *slot.borrow_mut() = Some((point, Box::new(hook))));
4367    }
4368
4369    fn git_stdout(dir: &Path, args: &[&str]) -> String {
4370        let out = Command::new("git") // hygiene: git fixture comparison in cfg(test) module
4371            .env("GIT_AUTHOR_NAME", "Test")
4372            .env("GIT_AUTHOR_EMAIL", "test@example.com")
4373            .env("GIT_COMMITTER_NAME", "Test")
4374            .env("GIT_COMMITTER_EMAIL", "test@example.com")
4375            .env("GIT_CONFIG_GLOBAL", "/dev/null")
4376            .env("GIT_CONFIG_SYSTEM", "/dev/null")
4377            .current_dir(dir)
4378            .args(args)
4379            .output()
4380            .expect("run git");
4381        assert!(out.status.success(), "git {args:?} failed");
4382        String::from_utf8(out.stdout)
4383            .expect("utf8")
4384            .trim()
4385            .to_owned()
4386    }
4387
4388    /// Fixture: a bare remote with `main`, plus a clone holding one more commit on `main` and
4389    /// a second, diverging commit already stored in the remote as `refs/heads/other`.
4390    /// Returns `(remote, local_git_dir, local_tip, other_tip)`.
4391    fn racing_create_fixture(tmp: &Path) -> (PathBuf, PathBuf, ObjectId, ObjectId) {
4392        let remote = tmp.join("remote.git");
4393        let local = tmp.join("local");
4394        std::fs::create_dir_all(&remote).unwrap();
4395        std::fs::create_dir_all(&local).unwrap();
4396        assert!(git_ok(&remote, &["init", "-q", "--bare", "-b", "main"]));
4397        assert!(git_ok(&local, &["init", "-q", "-b", "main"]));
4398        assert!(git_ok(&local, &["commit", "--allow-empty", "-qm", "base"]));
4399        assert!(git_ok(
4400            &local,
4401            &["push", "-q", remote.to_str().unwrap(), "main"]
4402        ));
4403        assert!(git_ok(&local, &["checkout", "-q", "-b", "other"]));
4404        assert!(git_ok(&local, &["commit", "--allow-empty", "-qm", "other"]));
4405        assert!(git_ok(
4406            &local,
4407            &["push", "-q", remote.to_str().unwrap(), "other"]
4408        ));
4409        let other = ObjectId::from_hex(&git_stdout(&local, &["rev-parse", "HEAD"])).unwrap();
4410        assert!(git_ok(&local, &["checkout", "-q", "main"]));
4411        assert!(git_ok(&local, &["commit", "--allow-empty", "-qm", "mine"]));
4412        let tip = ObjectId::from_hex(&git_stdout(&local, &["rev-parse", "HEAD"])).unwrap();
4413        (remote, local.join(".git"), tip, other)
4414    }
4415
4416    #[cfg_attr(
4417        not(feature = "full-tests"),
4418        ignore = "starts system git or is heavy; run with --features full-tests"
4419    )]
4420    #[test]
4421    fn push_local_create_rejects_ref_created_after_decision() {
4422        let points = [
4423            PushHookPoint::BeforeReceivePolicy,
4424            PushHookPoint::BeforeRefCommit,
4425        ];
4426        let modes = [(false, false), (true, false), (false, true)];
4427        for (point, (force, atomic)) in points
4428            .into_iter()
4429            .flat_map(|p| modes.into_iter().map(move |m| (p, m)))
4430        {
4431            let tmp = tempfile::tempdir().unwrap();
4432            let (remote, local_git, tip, other) = racing_create_fixture(tmp.path());
4433
4434            let racer_remote = remote.clone();
4435            on_next_push(point, move || {
4436                assert!(git_ok(
4437                    &racer_remote,
4438                    &["update-ref", "refs/heads/shared", &other.to_hex(), ""]
4439                ));
4440            });
4441            let outcome = push_local(
4442                &local_git,
4443                &remote,
4444                &[PushRefSpec {
4445                    src: Some(tip),
4446                    dst: "refs/heads/shared".to_owned(),
4447                    force,
4448                    ..Default::default()
4449                }],
4450                &PushOptions {
4451                    atomic,
4452                    ..Default::default()
4453                },
4454                &crate::progress::NeverCancel,
4455            )
4456            .expect("push call");
4457
4458            let result = &outcome.results[0];
4459            let case = format!("{point:?} force={force} atomic={atomic}");
4460            assert_eq!(result.status, PushRefStatus::RejectAlreadyExists, "{case}");
4461            assert!(result.status.is_error(), "{case}");
4462            assert_eq!(result.message.as_deref(), Some("already exists"), "{case}");
4463            assert_eq!(
4464                git_stdout(&remote, &["rev-parse", "refs/heads/shared"]),
4465                other.to_hex(),
4466                "{case}: the racing creator's value must survive"
4467            );
4468            assert!(!git_ok(
4469                &remote,
4470                &[
4471                    "merge-base",
4472                    "--is-ancestor",
4473                    &tip.to_hex(),
4474                    &other.to_hex()
4475                ]
4476            ));
4477        }
4478    }
4479
4480    #[cfg_attr(
4481        not(feature = "full-tests"),
4482        ignore = "starts system git or is heavy; run with --features full-tests"
4483    )]
4484    #[test]
4485    fn push_local_update_rejects_ref_moved_after_decision() {
4486        for point in [
4487            PushHookPoint::BeforeReceivePolicy,
4488            PushHookPoint::BeforeRefCommit,
4489        ] {
4490            let tmp = tempfile::tempdir().unwrap();
4491            let (remote, local_git, tip, other) = racing_create_fixture(tmp.path());
4492
4493            let racer_remote = remote.clone();
4494            on_next_push(point, move || {
4495                assert!(git_ok(
4496                    &racer_remote,
4497                    &["update-ref", "refs/heads/main", &other.to_hex()]
4498                ));
4499            });
4500            let outcome = push_local(
4501                &local_git,
4502                &remote,
4503                &[PushRefSpec {
4504                    src: Some(tip),
4505                    dst: "refs/heads/main".to_owned(),
4506                    ..Default::default()
4507                }],
4508                &PushOptions::default(),
4509                &crate::progress::NeverCancel,
4510            )
4511            .expect("push call");
4512
4513            assert_eq!(
4514                outcome.results[0].status,
4515                PushRefStatus::RejectStale,
4516                "{point:?}"
4517            );
4518            assert_eq!(
4519                git_stdout(&remote, &["rev-parse", "refs/heads/main"]),
4520                other.to_hex(),
4521                "{point:?}"
4522            );
4523        }
4524    }
4525
4526    fn assert_monotonic(counts: &[u64]) {
4527        for pair in counts.windows(2) {
4528            assert!(pair[1] >= pair[0], "progress counts must be monotonic");
4529        }
4530    }
4531
4532    fn copying_phase_total(rec: &PhaseRecorder) -> Option<u64> {
4533        rec.started
4534            .iter()
4535            .find(|(phase, _)| *phase == ProgressPhase::CopyingObjects)
4536            .and_then(|(_, total)| *total)
4537    }
4538
4539    fn assert_copy_progress_within_total(rec: &PhaseRecorder, copied_objects: u64) {
4540        let Some(total) = copying_phase_total(rec) else {
4541            panic!("missing CopyingObjects start: {:?}", rec.started);
4542        };
4543        assert!(
4544            total >= copied_objects,
4545            "progress total {total} smaller than copied objects {copied_objects}"
4546        );
4547        for &count in &rec.copying_counts {
4548            assert!(
4549                count <= total,
4550                "copy progress {count} exceeds phase total {total}"
4551            );
4552        }
4553        for snapshot in &rec.received {
4554            assert!(
4555                snapshot.received <= snapshot.total,
4556                "received {} exceeds snapshot total {}",
4557                snapshot.received,
4558                snapshot.total
4559            );
4560            assert!(
4561                snapshot.total <= total,
4562                "snapshot total {} exceeds phase total {}",
4563                snapshot.total,
4564                total
4565            );
4566            assert!(
4567                snapshot.received <= total,
4568                "received {} exceeds phase total {}",
4569                snapshot.received,
4570                total
4571            );
4572        }
4573        assert_eq!(
4574            rec.copying_counts.last().copied().unwrap_or(0),
4575            copied_objects,
4576            "final copy count should match transferred objects"
4577        );
4578    }
4579
4580    #[cfg_attr(
4581        not(feature = "full-tests"),
4582        ignore = "starts system git or is heavy; run with --features full-tests"
4583    )]
4584    #[test]
4585    fn local_fetch_classifies_through_repository_ref_store() {
4586        use crate::refs::store::{MemoryRefStore, RawRef};
4587        use std::sync::Arc;
4588
4589        let tmp = tempfile::tempdir().unwrap();
4590        assert!(git_ok(tmp.path(), &["init", "-q", "-b", "main"]));
4591        assert!(git_ok(
4592            tmp.path(),
4593            &["commit", "--allow-empty", "-qm", "base"]
4594        ));
4595        let repo =
4596            crate::repo::Repository::open(&tmp.path().join(".git"), &RepositoryOptions::empty())
4597                .unwrap();
4598        let tip = repo.resolve_ref_oid("HEAD").unwrap();
4599        let store = Arc::new(MemoryRefStore::new());
4600        store
4601            .set_ref("refs/remotes/origin/main", RawRef::Direct(tip))
4602            .unwrap();
4603        let repo = repo.with_ref_store(store);
4604        let opts = {
4605            let mut opts = FetchOptions::new();
4606            opts.refspecs = vec!["+refs/heads/*:refs/remotes/origin/*".into()];
4607            opts.tags = TagMode::None;
4608            opts.dry_run = true;
4609            opts
4610        };
4611        let outcome = fetch_local_into(
4612            &repo,
4613            &tmp.path().join(".git"),
4614            &opts,
4615            &mut PhaseRecorder::default(),
4616            &crate::progress::NeverCancel,
4617        )
4618        .unwrap();
4619        assert_eq!(outcome.transfer.objects, 0);
4620        assert_eq!(outcome.updates.len(), 1);
4621        assert_eq!(outcome.updates[0].old_oid, Some(tip));
4622        assert_eq!(outcome.updates[0].mode, UpdateMode::UpToDate);
4623    }
4624
4625    fn write_linear_commits(objects: &Path, depth: usize) -> (Odb, ObjectId) {
4626        use crate::objects::{serialize_commit, CommitData};
4627        let odb = Odb::new(objects);
4628        let mut parent = None;
4629        for i in 0..depth {
4630            let blob = odb
4631                .write(ObjectKind::Blob, format!("payload {i}\n").as_bytes())
4632                .expect("blob");
4633            let mut tree = b"100644 f.txt\0".to_vec();
4634            tree.extend_from_slice(blob.as_bytes());
4635            let tree_oid = odb.write(ObjectKind::Tree, &tree).expect("tree");
4636            let commit = CommitData {
4637                tree: tree_oid,
4638                parents: parent.into_iter().collect(),
4639                author: "T <t@e.com> 0 +0000".into(),
4640                committer: "T <t@e.com> 0 +0000".into(),
4641                author_raw: Vec::new(),
4642                committer_raw: Vec::new(),
4643                encoding: None,
4644                message: format!("c{i}\n"),
4645                raw_message: None,
4646                extra_headers: Vec::new(),
4647            };
4648            parent = Some(
4649                odb.write(ObjectKind::Commit, &serialize_commit(&commit))
4650                    .expect("commit"),
4651            );
4652        }
4653        (odb, parent.expect("tip"))
4654    }
4655
4656    #[test]
4657    fn fetch_local_hardlinked_pack_reports_scan_progress() {
4658        let remote = tempfile::tempdir().expect("remote");
4659        assert!(git_ok(
4660            remote.path(),
4661            &["init", "-q", "-b", "main", "--bare"]
4662        ));
4663        let src = tempfile::tempdir().expect("src");
4664        assert!(git_ok(src.path(), &["init", "-q", "-b", "main"]));
4665        for i in 0..280 {
4666            std::fs::write(src.path().join("f.txt"), format!("payload {i}\n")).unwrap();
4667            assert!(git_ok(src.path(), &["add", "f.txt"]));
4668            assert!(git_ok(src.path(), &["commit", "-qm", &format!("c{i}")],));
4669        }
4670        assert!(git_ok(
4671            src.path(),
4672            &["push", "-q", remote.path().to_str().unwrap(), "main:main"],
4673        ));
4674        assert!(git_ok(remote.path(), &["repack", "-a", "-d", "-f"],));
4675
4676        let local = tempfile::tempdir().expect("local");
4677        init_repository(local.path(), false, "main", None, RefStorageFormat::Files).expect("init");
4678
4679        let opts = FetchOptions {
4680            refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
4681            initial_remote_fetch: true,
4682            tags: TagMode::None,
4683            ..Default::default()
4684        };
4685        let mut rec = PhaseRecorder {
4686            want_received: true,
4687            ..Default::default()
4688        };
4689        let outcome = fetch_local_with_progress(
4690            &local.path().join(".git"),
4691            remote.path(),
4692            &opts,
4693            &mut rec,
4694            &crate::progress::NeverCancel,
4695        )
4696        .expect("fetch_local");
4697        assert!(
4698            outcome.transfer.objects > 500,
4699            "expected a large hardlinked pack transfer"
4700        );
4701        assert!(
4702            rec.enumerating_counts.len() >= 3,
4703            "expected pack index scan progress under EnumeratingObjects, got {:?}",
4704            rec.enumerating_counts
4705        );
4706        assert_monotonic(&rec.enumerating_counts);
4707        assert_eq!(
4708            rec.started
4709                .iter()
4710                .filter(|(p, _)| *p == ProgressPhase::CopyingObjects)
4711                .count(),
4712            1,
4713            "expected a single CopyingObjects phase, got {:?}",
4714            rec.started
4715        );
4716        assert!(
4717            !rec.copying_counts.is_empty(),
4718            "expected copy progress after pack install, got {:?}",
4719            rec.copying_counts
4720        );
4721        assert_monotonic(&rec.copying_counts);
4722        assert!(
4723            rec.received.last().is_some_and(|s| s.received > 0),
4724            "expected received_objects only after objects were installed"
4725        );
4726    }
4727
4728    #[test]
4729    fn fetch_local_packed_remote_reports_incremental_copy_progress() {
4730        let remote = tempfile::tempdir().expect("remote");
4731        init_repository(remote.path(), true, "main", None, RefStorageFormat::Files)
4732            .expect("remote init");
4733        let objects = remote.path().join("objects");
4734        let (remote_odb, tip) = write_linear_commits(&objects, 280);
4735        crate::refs::write_ref(remote.path(), "refs/heads/main", &tip).expect("main ref");
4736        remote_odb
4737            .write(ObjectKind::Blob, b"unreachable orphan blob\n")
4738            .expect("orphan");
4739
4740        let local = tempfile::tempdir().expect("local");
4741        init_repository(local.path(), false, "main", None, RefStorageFormat::Files).expect("init");
4742
4743        let opts = {
4744            let mut opts = FetchOptions::new();
4745            opts.refspecs = vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()];
4746            opts.tags = TagMode::None;
4747            opts = opts.initial_remote_fetch(true);
4748            opts
4749        };
4750        let mut rec = PhaseRecorder {
4751            want_received: true,
4752            ..Default::default()
4753        };
4754        let outcome = fetch_local_with_progress(
4755            &local.path().join(".git"),
4756            remote.path(),
4757            &opts,
4758            &mut rec,
4759            &crate::progress::NeverCancel,
4760        )
4761        .expect("fetch_local");
4762        assert!(
4763            !rec.received.is_empty(),
4764            "expected received_objects callbacks"
4765        );
4766        for snapshot in &rec.received {
4767            assert!(snapshot.received <= snapshot.total);
4768        }
4769
4770        let enum_pos = rec
4771            .started
4772            .iter()
4773            .position(|(p, _)| *p == ProgressPhase::EnumeratingObjects)
4774            .expect("EnumeratingObjects");
4775        let copy_pos = rec
4776            .started
4777            .iter()
4778            .position(|(p, _)| *p == ProgressPhase::CopyingObjects)
4779            .expect("CopyingObjects");
4780        assert!(
4781            enum_pos < copy_pos,
4782            "enumerate before copy: {:?}",
4783            rec.started
4784        );
4785        assert_eq!(
4786            rec.started
4787                .iter()
4788                .filter(|(p, _)| *p == ProgressPhase::CopyingObjects)
4789                .count(),
4790            1,
4791            "expected a single CopyingObjects phase, got {:?}",
4792            rec.started
4793        );
4794        assert!(
4795            rec.counts.len() >= 3,
4796            "expected >=3 progress samples overall, got {:?}",
4797            rec.counts
4798        );
4799        assert!(
4800            rec.copying_counts.len() >= 3,
4801            "expected >=3 copy progress samples, got {:?}",
4802            rec.copying_counts
4803        );
4804        assert_monotonic(&rec.copying_counts);
4805        assert!(
4806            rec.copying_counts.last().copied().unwrap_or(0) >= outcome.transfer.objects as u64,
4807            "final copy count should reach transferred objects"
4808        );
4809    }
4810
4811    #[test]
4812    fn link_source_objects_progress_counts_objects_not_sidecar_files() {
4813        for want_received in [false, true] {
4814            let remote = tempfile::tempdir().expect("remote");
4815            assert!(git_ok(
4816                remote.path(),
4817                &["init", "-q", "-b", "main", "--bare"]
4818            ));
4819            let src = tempfile::tempdir().expect("src");
4820            assert!(git_ok(src.path(), &["init", "-q", "-b", "main"]));
4821            for i in 0..20 {
4822                std::fs::write(src.path().join("f.txt"), format!("payload {i}\n")).unwrap();
4823                assert!(git_ok(src.path(), &["add", "f.txt"]));
4824                assert!(git_ok(src.path(), &["commit", "-qm", &format!("c{i}")],));
4825            }
4826            assert!(git_ok(
4827                src.path(),
4828                &["push", "-q", remote.path().to_str().unwrap(), "main:main"],
4829            ));
4830            assert!(git_ok(remote.path(), &["repack", "-a", "-d", "-f"],));
4831
4832            let local = tempfile::tempdir().expect("local");
4833            init_repository(local.path(), false, "main", None, RefStorageFormat::Files)
4834                .expect("init");
4835
4836            let opts = {
4837                let mut opts = FetchOptions::new();
4838                opts.refspecs = vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()];
4839                opts.tags = TagMode::None;
4840                opts.link_source_objects = true;
4841                opts = opts.initial_remote_fetch(true);
4842                opts
4843            };
4844            let mut rec = PhaseRecorder {
4845                want_received,
4846                ..Default::default()
4847            };
4848            let outcome = fetch_local_with_progress(
4849                &local.path().join(".git"),
4850                remote.path(),
4851                &opts,
4852                &mut rec,
4853                &crate::progress::NeverCancel,
4854            )
4855            .expect("fetch_local");
4856
4857            if want_received {
4858                assert!(
4859                    !rec.received.is_empty(),
4860                    "expected received_objects when enabled"
4861                );
4862            } else {
4863                assert!(
4864                    rec.received.is_empty(),
4865                    "expected no received_objects when disabled"
4866                );
4867            }
4868            assert_copy_progress_within_total(&rec, outcome.transfer.objects as u64);
4869            assert_ne!(
4870                copying_phase_total(&rec),
4871                Some(1),
4872                "total must be object count, not ref count: {:?}",
4873                rec.started
4874            );
4875        }
4876    }
4877
4878    #[cfg_attr(
4879        not(feature = "full-tests"),
4880        ignore = "starts system git or is heavy; run with --features full-tests"
4881    )]
4882    #[test]
4883    fn fetch_local_reports_copy_progress() {
4884        let remote = tempfile::tempdir().expect("remote");
4885        assert!(git_ok(
4886            remote.path(),
4887            &["init", "-q", "-b", "main", "--bare"]
4888        ));
4889        let src = tempfile::tempdir().expect("src");
4890        assert!(git_ok(src.path(), &["init", "-q", "-b", "main"]));
4891        std::fs::write(src.path().join("f.txt"), "payload\n").unwrap();
4892        assert!(git_ok(src.path(), &["add", "f.txt"]));
4893        assert!(git_ok(src.path(), &["commit", "-qm", "c1"]));
4894        assert!(git_ok(
4895            src.path(),
4896            &["push", "-q", remote.path().to_str().unwrap(), "main:main",],
4897        ));
4898
4899        let local = tempfile::tempdir().expect("local");
4900        init_repository(local.path(), false, "main", None, RefStorageFormat::Files).expect("init");
4901
4902        let opts = {
4903            let mut opts = FetchOptions::new();
4904            opts.refspecs = vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()];
4905            opts.tags = TagMode::None;
4906            opts = opts.initial_remote_fetch(true);
4907            opts
4908        };
4909        let mut rec = PhaseRecorder::default();
4910        fetch_local_with_progress(
4911            &local.path().join(".git"),
4912            remote.path(),
4913            &opts,
4914            &mut rec,
4915            &crate::progress::NeverCancel,
4916        )
4917        .expect("fetch_local");
4918
4919        assert!(
4920            rec.started
4921                .iter()
4922                .any(|(p, _)| *p == ProgressPhase::CopyingObjects),
4923            "expected CopyingObjects phase"
4924        );
4925        assert!(
4926            rec.finished.contains(&ProgressPhase::CopyingObjects),
4927            "expected CopyingObjects finish"
4928        );
4929        assert!(!rec.counts.is_empty(), "expected copy progress counts");
4930        assert_monotonic(&rec.counts);
4931        let first_progress = rec.events.iter().find(|e| **e != "start").copied();
4932        assert_eq!(
4933            rec.events.first().copied(),
4934            Some("start"),
4935            "CopyingObjects must start before link/copy work reports progress"
4936        );
4937        assert!(
4938            first_progress.is_some(),
4939            "expected set/inc after start during copy"
4940        );
4941    }
4942
4943    #[test]
4944    fn transfer_stats_add_up_and_keep_kinds_only_when_known() {
4945        use crate::index_pack::ObjectKindCounts;
4946        let kinds = |commits, blobs| ObjectKindCounts {
4947            commits,
4948            trees: 0,
4949            blobs,
4950            tags: 0,
4951        };
4952        let mut total = TransferStats::default();
4953        total += TransferStats {
4954            objects: 3,
4955            bytes: 100,
4956            kinds: Some(kinds(1, 2)),
4957        };
4958        // An up-to-date remote adds nothing and does not erase the breakdown.
4959        total += TransferStats::default();
4960        total += TransferStats {
4961            objects: 2,
4962            bytes: 50,
4963            kinds: Some(kinds(1, 1)),
4964        };
4965        assert_eq!(
4966            total,
4967            TransferStats {
4968                objects: 5,
4969                bytes: 150,
4970                kinds: Some(kinds(2, 3)),
4971            }
4972        );
4973
4974        // A transfer without a breakdown makes the combined breakdown unknown.
4975        total += TransferStats {
4976            objects: 4,
4977            bytes: 10,
4978            kinds: None,
4979        };
4980        assert_eq!(total.objects, 9);
4981        assert_eq!(total.bytes, 160);
4982        assert_eq!(total.kinds, None);
4983    }
4984
4985    #[cfg_attr(
4986        not(feature = "full-tests"),
4987        ignore = "starts system git or is heavy; run with --features full-tests"
4988    )]
4989    #[test]
4990    fn transfer_stats_for_local_operation_counts_new_pack_index() {
4991        let tmp = tempfile::tempdir().expect("tempdir");
4992        let repo = tmp.path().join("repo");
4993        std::fs::create_dir_all(&repo).unwrap();
4994        assert!(git_ok(&repo, &["init", "-q", "-b", "main", "."]));
4995        std::fs::write(repo.join("blob.txt"), b"payload\n").unwrap();
4996        assert!(git_ok(&repo, &["add", "blob.txt"]));
4997        assert!(git_ok(&repo, &["commit", "-qm", "c"]));
4998        assert!(git_ok(&repo, &["repack", "-a", "-d", "-f"]));
4999
5000        let git_dir = repo.join(".git");
5001        let pack_dir = git_dir.join("objects/pack");
5002        let odb = Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.clone());
5003        let stats =
5004            transfer_stats_for_local_operation(&odb, &HashSet::new(), None, &HashSet::new())
5005                .expect("stats");
5006        assert!(stats.bytes > 0);
5007        assert!(stats.objects > 0);
5008
5009        let pack_path = std::fs::read_dir(&pack_dir)
5010            .expect("read pack dir")
5011            .filter_map(|e| e.ok())
5012            .map(|e| e.path())
5013            .find(|p| p.extension().is_some_and(|e| e == "pack"))
5014            .expect("pack file");
5015        let idx = pack_path.with_extension("idx");
5016        let index = crate::pack::read_pack_index_no_verify(&idx).expect("idx");
5017        assert_eq!(
5018            stats.objects,
5019            u64::try_from(index.len()).expect("count"),
5020            "object count must match the on-disk index entry count"
5021        );
5022    }
5023
5024    #[test]
5025    fn update_mode_modified_repo_excludes_rejected_tag_updates() {
5026        assert!(UpdateMode::New.modified_repo());
5027        assert!(UpdateMode::DeletedMissing.modified_repo());
5028        assert!(!UpdateMode::TagUpdateRejected.modified_repo());
5029        assert!(!UpdateMode::UpToDate.modified_repo());
5030    }
5031
5032    #[test]
5033    fn update_mode_report_classification_covers_every_mode() {
5034        use UpdateMode::*;
5035        for (mode, reported, rejected) in [
5036            (New, true, false),
5037            (FastForward, true, false),
5038            (Forced, true, false),
5039            (DeletedMissing, true, false),
5040            (NonFastForwardRejected, true, true),
5041            (TagUpdateRejected, true, true),
5042            (SourceObjectNotFound, true, true),
5043            (UpToDate, false, false),
5044            (NoChangeNeeded, false, false),
5045            (Unborn, false, false),
5046        ] {
5047            assert_eq!(mode.is_reported(), reported, "{mode:?}");
5048            assert_eq!(mode.is_rejected(), rejected, "{mode:?}");
5049            assert!(!(mode.modified_repo() && mode.is_rejected()), "{mode:?}");
5050        }
5051    }
5052
5053    #[test]
5054    fn fetch_outcome_summarizes_reported_changed_and_rejected_refs() {
5055        let update = |name: &str, mode| RefUpdate {
5056            remote_ref: format!("refs/heads/{name}"),
5057            local_ref: Some(format!("refs/remotes/origin/{name}")),
5058            old_oid: None,
5059            new_oid: None,
5060            mode,
5061            note: None,
5062        };
5063        let outcome = FetchOutcome {
5064            updates: vec![
5065                update("same", UpdateMode::UpToDate),
5066                update("new", UpdateMode::New),
5067                update("ff", UpdateMode::FastForward),
5068                update("noop", UpdateMode::NoChangeNeeded),
5069            ],
5070            ..Default::default()
5071        };
5072        let reported: Vec<&str> = outcome
5073            .reported_updates()
5074            .map(|u| u.remote_ref.as_str())
5075            .collect();
5076        assert_eq!(reported, ["refs/heads/new", "refs/heads/ff"]);
5077        assert_eq!(outcome.changed_ref_count(), 2);
5078        assert!(!outcome.has_rejections());
5079
5080        let rejected = FetchOutcome {
5081            updates: vec![update("diverged", UpdateMode::NonFastForwardRejected)],
5082            ..Default::default()
5083        };
5084        assert_eq!(rejected.changed_ref_count(), 0);
5085        assert!(rejected.has_rejections());
5086        assert_eq!(rejected.reported_updates().count(), 1);
5087        assert!(FetchOutcome::default().reported_updates().next().is_none());
5088    }
5089
5090    #[test]
5091    fn update_mode_kind_covers_every_mode() {
5092        use UpdateMode::*;
5093        for (mode, kind) in [
5094            (New, UpdateKind::New),
5095            (FastForward, UpdateKind::FastForward),
5096            (Forced, UpdateKind::Forced),
5097            (DeletedMissing, UpdateKind::Pruned),
5098            (NonFastForwardRejected, UpdateKind::Rejected),
5099            (TagUpdateRejected, UpdateKind::Rejected),
5100            (SourceObjectNotFound, UpdateKind::Rejected),
5101            (RefLockRejected, UpdateKind::Rejected),
5102            (UpToDate, UpdateKind::UpToDate),
5103            (NoChangeNeeded, UpdateKind::UpToDate),
5104            (Unborn, UpdateKind::UpToDate),
5105        ] {
5106            assert_eq!(mode.kind(), kind, "{mode:?}");
5107            assert_eq!(kind.changes_ref(), mode.modified_repo(), "{mode:?}");
5108            assert_eq!(kind == UpdateKind::Rejected, mode.is_rejected(), "{mode:?}");
5109        }
5110    }
5111
5112    #[test]
5113    fn push_outcome_reports_updates_and_rejections() {
5114        let result = |status| PushRefResult {
5115            local_ref: Some("refs/heads/main".into()),
5116            remote_ref: "refs/heads/main".into(),
5117            old_oid: None,
5118            new_oid: None,
5119            forced: false,
5120            deletion: false,
5121            status,
5122            message: None,
5123        };
5124        let outcome = |statuses: Vec<PushRefStatus>| PushOutcome {
5125            results: statuses.into_iter().map(result).collect(),
5126            remote_messages: Vec::new(),
5127        };
5128
5129        let empty = PushOutcome::default();
5130        assert!(!empty.has_updates());
5131        assert!(!empty.has_rejections());
5132
5133        let up_to_date = outcome(vec![PushRefStatus::UpToDate]);
5134        assert!(!up_to_date.has_updates());
5135        assert!(!up_to_date.has_rejections());
5136
5137        let pushed = outcome(vec![PushRefStatus::UpToDate, PushRefStatus::Ok]);
5138        assert!(pushed.has_updates());
5139        assert!(!pushed.has_rejections());
5140
5141        let mixed = outcome(vec![PushRefStatus::Ok, PushRefStatus::RemoteRejected]);
5142        assert!(mixed.has_updates());
5143        assert!(mixed.has_rejections());
5144
5145        let refused = outcome(vec![PushRefStatus::AtomicPushFailed]);
5146        assert!(!refused.has_updates());
5147        assert!(refused.has_rejections());
5148    }
5149}