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