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