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::path::{Path, PathBuf};
24
25use crate::error::{Error, RemoteUnreadableReason, Result};
26use crate::fetch::{NoProgress, Progress};
27use crate::objects::{parse_commit, parse_tag, parse_tree, HashAlgo, ObjectId, ObjectKind};
28use crate::odb::Odb;
29use crate::progress::{ProgressPhase, ProgressSink};
30use crate::push_report::{PushRefResult, PushRefStatus};
31use crate::refspec::{parse_fetch_refspec, RefspecItem};
32use std::num::NonZeroU32;
33
34/// How a single reference resolved during a fetch (or would resolve in a push).
35///
36/// Mirrors the shapes of `gix::remote::fetch::refs::update::Mode` that `jj`
37/// already consumes, so the embedder's translation layer stays a thin adapter.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum UpdateMode {
40    /// The local tracking ref did not exist and was created.
41    New,
42    /// The update advanced the ref along its existing history.
43    FastForward,
44    /// A non-fast-forward update that was applied because force was requested.
45    Forced,
46    /// The local ref already matched the remote value; nothing to do.
47    UpToDate,
48    /// No change was required (e.g. a no-op refspec).
49    NoChangeNeeded,
50    /// A non-fast-forward update that was rejected (force not requested).
51    NonFastForwardRejected,
52    /// A tag update was rejected (tags are not overwritten without force).
53    TagUpdateRejected,
54    /// The source object named by the refspec was not found on the remote.
55    SourceObjectNotFound,
56    /// The remote ref is unborn (points at nothing yet).
57    Unborn,
58    /// A prune/delete was requested but the local ref was already missing.
59    DeletedMissing,
60    /// The local tracking ref could not be created or updated (namespace conflict, lock, …).
61    RefLockRejected,
62}
63
64impl UpdateMode {
65    /// Returns whether fetch applied this update to the local repository.
66    ///
67    /// Rejected or no-op outcomes (for example an existing tag that moved on the
68    /// remote) are excluded so callers can report only refs that actually changed.
69    #[must_use]
70    pub fn modified_repo(self) -> bool {
71        matches!(
72            self,
73            Self::New | Self::FastForward | Self::Forced | Self::DeletedMissing
74        )
75    }
76
77    /// Returns whether fetch refused this update (non-fast-forward without force, a moved
78    /// tag, or a source missing on the remote).
79    #[must_use]
80    pub fn is_rejected(self) -> bool {
81        matches!(
82            self,
83            Self::NonFastForwardRejected
84                | Self::TagUpdateRejected
85                | Self::SourceObjectNotFound
86                | Self::RefLockRejected
87        )
88    }
89
90    /// Returns whether this update belongs in a fetch report: the ref changed
91    /// ([`Self::modified_repo`]) or the update was refused ([`Self::is_rejected`]).
92    ///
93    /// Up-to-date, no-op and unborn refs are left out.
94    #[must_use]
95    pub fn is_reported(self) -> bool {
96        self.modified_repo() || self.is_rejected()
97    }
98}
99
100/// The resolved outcome of one reference during a fetch.
101#[derive(Clone, Debug)]
102pub struct RefUpdate {
103    /// The remote-side ref name (e.g. `refs/heads/main`).
104    pub remote_ref: String,
105    /// The local-side ref name written, if any (e.g. `refs/remotes/origin/main`).
106    pub local_ref: Option<String>,
107    /// Previous value of the local ref (`None` when newly created).
108    pub old_oid: Option<ObjectId>,
109    /// New value written to the local ref (`None` for deletions / unborn).
110    pub new_oid: Option<ObjectId>,
111    /// How the update resolved.
112    pub mode: UpdateMode,
113    /// Optional human-readable note (reason text), for embedder display.
114    pub note: Option<String>,
115}
116
117/// Which tags to fetch alongside the requested refs.
118#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
119pub enum TagMode {
120    /// Do not fetch any tags automatically.
121    None,
122    /// Fetch tags that point into the history being fetched (like `git fetch`'s
123    /// default tag-following; not what `git clone` does).
124    ///
125    /// A tag is kept when the object it peels to arrives with this fetch, when
126    /// it is a commit already present locally and reachable from the fetched
127    /// refs, or when it is a tree or blob already present locally. Annotated
128    /// tags also need their tag object. Deepening or unshallowing follows tags
129    /// into the newly available history in the same fetch, without letting
130    /// automatic tags expand the requested branch depth.
131    ///
132    /// Git also keeps a tag on a local commit
133    /// that the fetched refs do not reach; grit does not. Deciding this reads
134    /// no objects for delivered targets, walks only commits for local ones, and
135    /// never lazy-fetches in a partial clone, so it stays cheap on large
136    /// histories.
137    ///
138    /// Tags the local repository already has at the advertised value are not
139    /// considered again, so they do not appear in [`FetchOutcome::updates`].
140    #[default]
141    Following,
142    /// Fetch every advertised `refs/tags/*` ref (same idea as `git clone` and
143    /// `git fetch --tags`).
144    All,
145}
146
147/// Shallow-history control for fetch and clone (`--depth`, `--deepen`, `--unshallow`).
148#[derive(Clone, Copy, Debug, PartialEq, Eq)]
149pub enum FetchDepth {
150    /// Truncate or establish history at the given absolute depth from each tip
151    /// (`git fetch --depth N`).
152    Depth(NonZeroU32),
153    /// Deepen an existing shallow repository by the given number of commits
154    /// (`git fetch --deepen N`, wire `deepen N` + `deepen-relative`).
155    Deepen(NonZeroU32),
156    /// Convert a shallow repository into a complete one (`git fetch --unshallow`).
157    Unshallow,
158}
159
160impl FetchDepth {
161    /// Build a [`FetchDepth::Depth`] when `n > 0`.
162    #[must_use]
163    pub fn depth(n: u32) -> Option<Self> {
164        NonZeroU32::new(n).map(Self::Depth)
165    }
166
167    /// Build a [`FetchDepth::Deepen`] when `n > 0`.
168    #[must_use]
169    pub fn deepen(n: u32) -> Option<Self> {
170        NonZeroU32::new(n).map(Self::Deepen)
171    }
172}
173
174/// Options controlling a fetch.
175pub struct FetchOptions {
176    /// Positive refspecs selecting what to fetch.
177    pub refspecs: Vec<String>,
178    /// Negative refspecs excluding refs from the positive set.
179    pub negative_refspecs: Vec<String>,
180    /// Tag-following policy.
181    pub tags: TagMode,
182    /// Whether to prune local tracking refs that vanished on the remote.
183    pub prune: bool,
184    /// Compute and report updates without writing any refs or objects.
185    pub dry_run: bool,
186    /// Shallow fetch control (`--depth`, `--deepen`, `--unshallow`). `None` leaves
187    /// history depth unchanged (aside from an existing on-disk `shallow` file).
188    pub fetch_depth: Option<FetchDepth>,
189    /// Deepen history to include commits no older than this cutoff
190    /// (`git fetch --shallow-since <date>`). The value is sent verbatim as the
191    /// wire `deepen-since <value>`; callers should pass the Unix timestamp Git's
192    /// `upload-pack` expects (a bare integer), not a human date string.
193    pub deepen_since: Option<String>,
194    /// Deepen history but stop at (exclude) commits reachable from these refs/oids
195    /// (`git fetch --shallow-exclude <ref>`). Each entry is sent as a wire
196    /// `deepen-not <ref>`.
197    pub deepen_not: Vec<String>,
198    /// First fetch populating a remote-tracking namespace (clone): write `packed-refs`
199    /// and `refs/remotes/{remote}/HEAD`.
200    pub initial_remote_fetch: bool,
201    /// Remote name paired with [`Self::initial_remote_fetch`] (e.g. `"origin"`).
202    pub remote_name: Option<String>,
203    /// Source label written into [`FETCH_HEAD`](https://git-scm.com/docs/git-fetch#_discussion).
204    /// [`crate::remote::Remote::fetch`] sets this from the fetch URL when unset.
205    pub fetch_source: Option<String>,
206    /// Configured `remote.<name>.fetch` refspecs (for opportunistic tracking when [`Self::refspecs`]
207    /// overrides them). [`crate::remote::Remote::fetch`] fills this for named remotes.
208    pub configured_refspecs: Vec<String>,
209    /// Clone-only reflog entries for `refs/remotes/{remote}/HEAD` (not remote branches).
210    pub clone_reflog: Option<CloneReflog>,
211    /// Diagnostic sink for [`crate::diagnostics::Trace::Network`] events.
212    pub diagnostics: Option<crate::diagnostics::DiagnosticsHandle>,
213    /// When true with [`Self::diagnostics`], fetch paths may emit network trace events.
214    pub network_trace: bool,
215    /// Override worker threads for index-pack after a received pack (`None` = config/default).
216    pub index_pack_threads: Option<usize>,
217    /// Global / embedder thread policy (`--threads`); applied when [`Self::index_pack_threads`] is unset.
218    pub thread_policy: crate::parallel::ThreadPolicy,
219    /// Discovery/config environment for pack ingest (`GIT_CONFIG_*`, `-c`); defaults to empty.
220    pub environment: crate::environment::Environment,
221    /// When true, spool the negotiated pack to disk and return without index-pack or ref updates.
222    pub pack_spool_only: bool,
223    /// Override the HTTP `Git-Protocol` header for this fetch (`None` → client default, usually v2).
224    pub http_protocol_version: Option<u8>,
225    /// When true, omit the `no-progress` capability/argument so the remote may
226    /// send side-band channel-2 progress. Default false preserves today's wire bytes.
227    pub remote_progress: bool,
228    /// Object filter for partial clone (`blob:none`, `blob:limit=<n>`). Omitted when
229    /// unset; promisor remotes may reapply `remote.*.partialclonefilter` automatically.
230    pub filter: Option<ObjectFilterSpec>,
231    /// When true, the received pack is written with a sibling `.promisor` marker.
232    ///
233    /// Set by the fetch driver when a filter was sent on the wire; callers should
234    /// leave this `false`.
235    pub mark_received_pack_promisor: bool,
236    /// Explicit object ids to fetch (promisor on-demand); skips refspec matching and ref updates.
237    pub object_wants: Vec<ObjectId>,
238    /// On-demand promisor object fetch: always mark the received pack promisor.
239    pub promisor_object_fetch: bool,
240}
241
242impl Default for FetchOptions {
243    fn default() -> Self {
244        Self {
245            refspecs: Vec::new(),
246            negative_refspecs: Vec::new(),
247            tags: TagMode::Following,
248            prune: false,
249            dry_run: false,
250            fetch_depth: None,
251            deepen_since: None,
252            deepen_not: Vec::new(),
253            initial_remote_fetch: false,
254            remote_name: None,
255            fetch_source: None,
256            configured_refspecs: Vec::new(),
257            clone_reflog: None,
258            diagnostics: None,
259            network_trace: false,
260            index_pack_threads: None,
261            thread_policy: crate::parallel::ThreadPolicy::default(),
262            environment: crate::environment::Environment::empty(),
263            pack_spool_only: false,
264            http_protocol_version: None,
265            remote_progress: false,
266            filter: None,
267            mark_received_pack_promisor: false,
268            object_wants: Vec::new(),
269            promisor_object_fetch: false,
270        }
271    }
272}
273
274/// Identity and message for clone reflog entries written by the library.
275#[derive(Clone, Debug)]
276pub struct CloneReflog {
277    /// Committer identity for the clone reflog line.
278    pub identity: crate::ident::Signature,
279    /// Reflog message (e.g. `clone: from <url>`).
280    pub message: String,
281}
282
283impl Clone for FetchOptions {
284    fn clone(&self) -> Self {
285        Self {
286            refspecs: self.refspecs.clone(),
287            negative_refspecs: self.negative_refspecs.clone(),
288            tags: self.tags,
289            prune: self.prune,
290            dry_run: self.dry_run,
291            fetch_depth: self.fetch_depth,
292            deepen_since: self.deepen_since.clone(),
293            deepen_not: self.deepen_not.clone(),
294            initial_remote_fetch: self.initial_remote_fetch,
295            remote_name: self.remote_name.clone(),
296            fetch_source: self.fetch_source.clone(),
297            configured_refspecs: self.configured_refspecs.clone(),
298            clone_reflog: self.clone_reflog.clone(),
299            diagnostics: self.diagnostics.clone(),
300            network_trace: self.network_trace,
301            index_pack_threads: self.index_pack_threads,
302            thread_policy: self.thread_policy,
303            environment: self.environment.clone(),
304            pack_spool_only: self.pack_spool_only,
305            http_protocol_version: self.http_protocol_version,
306            remote_progress: self.remote_progress,
307            filter: self.filter,
308            mark_received_pack_promisor: self.mark_received_pack_promisor,
309            object_wants: self.object_wants.clone(),
310            promisor_object_fetch: self.promisor_object_fetch,
311        }
312    }
313}
314
315impl FetchOptions {
316    /// Whether this fetch carries any shallow/deepen request (an explicit
317    /// `fetch_depth`/`deepen-since`/`deepen-not`). Note this does NOT cover the
318    /// "already shallow, fetching more of the same boundary" case — that is
319    /// driven by the on-disk `shallow` file, checked separately by the fetch
320    /// paths via [`crate::shallow::load_shallow_oids`].
321    #[must_use]
322    pub fn has_deepen_request(&self) -> bool {
323        self.fetch_depth.is_some()
324            || self
325                .deepen_since
326                .as_deref()
327                .is_some_and(|v| !v.trim().is_empty())
328            || self.deepen_not.iter().any(|v| !v.trim().is_empty())
329    }
330
331    /// Wire `deepen N` value and whether `deepen-relative` must follow.
332    #[must_use]
333    pub(crate) fn wire_deepen(&self) -> Option<(u32, bool)> {
334        match self.fetch_depth {
335            Some(FetchDepth::Depth(n)) => Some((n.get(), false)),
336            Some(FetchDepth::Deepen(n)) => Some((n.get(), true)),
337            Some(FetchDepth::Unshallow) => Some((crate::shallow::INFINITE_DEPTH, false)),
338            None => None,
339        }
340    }
341
342    /// Absolute depth hint for tag-following on a shallow fetch (`--depth` only).
343    #[must_use]
344    pub(crate) fn absolute_depth_for_tags(&self) -> Option<u32> {
345        match self.fetch_depth {
346            Some(FetchDepth::Depth(n)) => Some(n.get()),
347            Some(FetchDepth::Deepen(_)) | Some(FetchDepth::Unshallow) | None => None,
348        }
349    }
350}
351
352/// Validate shallow fetch options against the local repository before negotiation.
353///
354/// # Errors
355///
356/// Returns [`Error::FetchUnshallowNotShallow`] when [`FetchDepth::Unshallow`] is
357/// requested but the repository has no shallow boundaries.
358pub fn validate_fetch_depth_local(local_git_dir: &Path, opts: &FetchOptions) -> Result<()> {
359    if matches!(opts.fetch_depth, Some(FetchDepth::Unshallow)) {
360        let local = crate::shallow::load_shallow_oids(local_git_dir)?;
361        if local.is_empty() {
362            return Err(Error::FetchUnshallowNotShallow);
363        }
364    }
365    Ok(())
366}
367
368/// Require server shallow/deepen capabilities for the requested fetch options.
369pub(crate) fn validate_fetch_depth_server(
370    opts: &FetchOptions,
371    v0_caps: Option<&str>,
372    v2_caps: Option<&[String]>,
373) -> Result<()> {
374    let needs_shallow = opts.has_deepen_request()
375        || opts.wire_deepen().is_some()
376        || opts
377            .deepen_since
378            .as_deref()
379            .is_some_and(|s| !s.trim().is_empty())
380        || opts.deepen_not.iter().any(|s| !s.trim().is_empty());
381    if !needs_shallow {
382        return Ok(());
383    }
384    if let Some(caps) = v0_caps {
385        if !caps.contains("shallow") {
386            return Err(Error::FetchShallowUnsupported);
387        }
388        if opts.wire_deepen().is_some_and(|(_, rel)| rel) && !caps.contains("deepen-relative") {
389            return Err(Error::FetchDeepenRelativeUnsupported);
390        }
391    } else if let Some(caps) = v2_caps {
392        if !crate::protocol_v2::fetch_supports_feature(caps, "shallow") {
393            return Err(Error::FetchShallowUnsupported);
394        }
395    }
396    Ok(())
397}
398
399/// Resolve whether a fetch should prune stale remote-tracking refs.
400///
401/// When `cli_prune` is [`Some`], that value wins (matching `--prune` /
402/// `--no-prune`). Otherwise reads `remote.<remote>.prune`, then `fetch.prune`,
403/// in Git precedence order. Unset keys default to `false`.
404///
405/// # Errors
406///
407/// Returns [`Error::Config`] when a configured prune key is not a valid boolean.
408pub fn resolve_fetch_prune(
409    config: &crate::config::ConfigSet,
410    remote: &str,
411    cli_prune: Option<bool>,
412) -> Result<bool> {
413    if let Some(enabled) = cli_prune {
414        return Ok(enabled);
415    }
416    let remote_key = format!("remote.{remote}.prune");
417    if let Some(value) = optional_config_bool(config, &remote_key)? {
418        return Ok(value);
419    }
420    if let Some(value) = optional_config_bool(config, "fetch.prune")? {
421        return Ok(value);
422    }
423    Ok(false)
424}
425
426/// Resolve tag-following policy for a fetch.
427///
428/// When `cli_tags` is [`Some`], `true` maps to [`TagMode::All`] (`--tags`) and
429/// `false` to [`TagMode::None`] (`--no-tags`). Otherwise reads
430/// `remote.<remote>.tagOpt` (`--tags` / `--no-tags`, Git-compatible) and defaults
431/// to [`TagMode::Following`].
432#[must_use]
433pub fn resolve_fetch_tags(
434    config: &crate::config::ConfigSet,
435    remote: &str,
436    cli_tags: Option<bool>,
437) -> TagMode {
438    if let Some(all) = cli_tags {
439        return if all { TagMode::All } else { TagMode::None };
440    }
441    let key = format!("remote.{remote}.tagOpt");
442    match config.get(&key).as_deref().map(str::trim) {
443        Some("--tags") | Some("tags") | Some("all") => TagMode::All,
444        Some("--no-tags") | Some("no-tags") | Some("no") | Some("never") => TagMode::None,
445        _ => TagMode::Following,
446    }
447}
448
449fn optional_config_bool(config: &crate::config::ConfigSet, key: &str) -> Result<Option<bool>> {
450    match config.get_bool(key) {
451        None => Ok(None),
452        Some(Ok(value)) => Ok(Some(value)),
453        Some(Err(message)) => Err(Error::Config(crate::error::ConfigError::Other(format!(
454            "invalid boolean for '{key}': {message}"
455        )))),
456    }
457}
458
459/// Object and byte counts for objects received during a fetch or clone.
460#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
461pub struct TransferStats {
462    /// Objects installed from the received pack (zero when nothing was transferred).
463    pub objects: u64,
464    /// Size in bytes of the received pack file (zero when nothing was transferred).
465    pub bytes: u64,
466    /// Objects by type, when the pack was indexed locally (`None` when packs were
467    /// linked or copied from a local repository without being re-indexed).
468    pub kinds: Option<crate::index_pack::ObjectKindCounts>,
469}
470
471impl TransferStats {
472    /// Build stats from an ingested pack (installed under `objects/pack/` or written
473    /// into a custom primary store).
474    #[must_use]
475    pub fn from_ingested_pack(ingested: &crate::index_pack::IngestedPack) -> Self {
476        Self {
477            objects: u64::try_from(ingested.object_ids.len()).unwrap_or(u64::MAX),
478            bytes: ingested.pack_bytes,
479            kinds: Some(ingested.object_counts),
480        }
481    }
482}
483
484/// Totals across several transfers, such as every remote of a fetch-all.
485///
486/// Counts and bytes add up. [`TransferStats::kinds`] stays known only while every
487/// transfer that received objects reported its type breakdown.
488impl std::ops::AddAssign for TransferStats {
489    fn add_assign(&mut self, other: Self) {
490        self.kinds = match (self.kinds, other.kinds) {
491            (Some(mut mine), Some(theirs)) => {
492                mine += theirs;
493                Some(mine)
494            }
495            (mine, None) if other.objects == 0 => mine,
496            (None, theirs) if self.objects == 0 => theirs,
497            _ => None,
498        };
499        self.objects += other.objects;
500        self.bytes += other.bytes;
501    }
502}
503
504/// The structured result of a fetch, ready for the embedder's ref-store apply.
505#[derive(Clone, Debug, Default)]
506pub struct FetchOutcome {
507    /// Per-ref resolved updates.
508    pub updates: Vec<RefUpdate>,
509    /// The remote's default branch (from `HEAD` symref), if known.
510    pub default_branch: Option<String>,
511    /// New shallow boundary commits the server reported (`shallow <oid>`), already
512    /// applied to the local `shallow` file. The commits' parents are intentionally
513    /// absent from the local object store after this fetch.
514    pub new_shallow: Vec<ObjectId>,
515    /// Commits the server reported as no longer shallow (`unshallow <oid>`), i.e.
516    /// boundaries removed from the local `shallow` file because their history is
517    /// now complete. Populated by a deepen / `--unshallow` fetch.
518    pub new_unshallow: Vec<ObjectId>,
519    /// Objects and pack bytes ingested during this fetch.
520    pub transfer: TransferStats,
521}
522
523impl FetchOutcome {
524    /// Count of remote branch refs represented in [`Self::updates`].
525    #[must_use]
526    pub fn branch_count(&self) -> u64 {
527        self.updates
528            .iter()
529            .filter(|u| u.remote_ref.starts_with("refs/heads/"))
530            .count() as u64
531    }
532
533    /// Count of remote tag refs represented in [`Self::updates`] (peeled lines excluded).
534    #[must_use]
535    pub fn tag_count(&self) -> u64 {
536        self.updates
537            .iter()
538            .filter(|u| u.remote_ref.starts_with("refs/tags/") && !u.remote_ref.ends_with("^{}"))
539            .count() as u64
540    }
541
542    /// Updates worth reporting to a user, in fetch order ([`UpdateMode::is_reported`]).
543    pub fn reported_updates(&self) -> impl Iterator<Item = &RefUpdate> {
544        self.updates.iter().filter(|u| u.mode.is_reported())
545    }
546
547    /// Number of local refs this fetch changed ([`UpdateMode::modified_repo`]).
548    ///
549    /// For a [`FetchOptions::dry_run`] fetch this counts the refs that *would* change;
550    /// nothing was written.
551    #[must_use]
552    pub fn changed_ref_count(&self) -> usize {
553        self.updates
554            .iter()
555            .filter(|u| u.mode.modified_repo())
556            .count()
557    }
558
559    /// Whether any ref update was refused ([`UpdateMode::is_rejected`]).
560    #[must_use]
561    pub fn has_rejections(&self) -> bool {
562        self.updates.iter().any(|u| u.mode.is_rejected())
563    }
564}
565
566fn object_kind_counts_for_oids(
567    odb: &Odb,
568    oids: &HashSet<ObjectId>,
569) -> Result<crate::index_pack::ObjectKindCounts> {
570    use crate::index_pack::ObjectKindCounts;
571    let mut kinds = ObjectKindCounts::default();
572    for oid in oids {
573        kinds.add(odb.read_info(oid)?.kind);
574    }
575    Ok(kinds)
576}
577
578fn loose_transfer_stats(objects_dir: &Path, oids: &HashSet<ObjectId>) -> Result<(u64, u64)> {
579    let mut objects = 0u64;
580    let mut bytes = 0u64;
581    for oid in oids {
582        let path = oid.loose_path_in(objects_dir);
583        if path.is_file() {
584            objects += 1;
585            bytes += fs::metadata(&path).map_err(Error::Io)?.len();
586        }
587    }
588    Ok((objects, bytes))
589}
590
591fn pack_file_basenames(pack_dir: &Path) -> HashSet<String> {
592    let mut names = HashSet::new();
593    let Ok(entries) = fs::read_dir(pack_dir) else {
594        return names;
595    };
596    for entry in entries.flatten() {
597        let path = entry.path();
598        if path.extension().is_some_and(|e| e == "pack") {
599            if let Some(name) = path.file_name() {
600                names.insert(name.to_string_lossy().into_owned());
601            }
602        }
603    }
604    names
605}
606
607const LOCAL_LINK_KINDS_OID_PROBE_MAX: u64 = 4096;
608
609fn oids_from_newly_linked_pack_indexes(
610    local_odb: &Odb,
611    pack_dir_before: &HashSet<String>,
612) -> Result<Vec<ObjectId>> {
613    let pack_dir = local_odb.objects_dir().join("pack");
614    let mut oids = Vec::new();
615    if !pack_dir.is_dir() {
616        return Ok(oids);
617    }
618    for entry in fs::read_dir(&pack_dir).map_err(Error::Io)? {
619        let path = entry.map_err(Error::Io)?.path();
620        if path.extension().is_none_or(|e| e != "pack") {
621            continue;
622        }
623        let Some(name) = path.file_name() else {
624            continue;
625        };
626        if pack_dir_before.contains(name.to_string_lossy().as_ref()) {
627            continue;
628        }
629        let idx = path.with_extension("idx");
630        if !idx.is_file() {
631            continue;
632        }
633        let index = crate::pack::read_pack_index_no_verify(&idx)?;
634        for entry in index.iter() {
635            let oid = ObjectId::from_bytes(entry.oid())?;
636            oids.push(oid);
637        }
638    }
639    Ok(oids)
640}
641
642fn enrich_local_link_transfer_kinds(
643    local_odb: &Odb,
644    pack_dir_before: &HashSet<String>,
645    loose_reachable: &HashSet<ObjectId>,
646    transfer: &mut TransferStats,
647) -> Result<()> {
648    if transfer.kinds.is_some() || transfer.objects == 0 {
649        return Ok(());
650    }
651    if transfer.objects > LOCAL_LINK_KINDS_OID_PROBE_MAX {
652        return Ok(());
653    }
654    let mut probe: HashSet<ObjectId> = loose_reachable.clone();
655    for oid in oids_from_newly_linked_pack_indexes(local_odb, pack_dir_before)? {
656        probe.insert(oid);
657    }
658    if u64::try_from(probe.len()).unwrap_or(u64::MAX) != transfer.objects {
659        return Ok(());
660    }
661    transfer.kinds = Some(object_kind_counts_for_oids(local_odb, &probe)?);
662    Ok(())
663}
664
665/// Per-fetch transfer stats for objects installed during one local copy operation.
666fn transfer_stats_for_local_operation(
667    local_odb: &Odb,
668    installed: &HashSet<ObjectId>,
669    pack_dir_before: &HashSet<String>,
670) -> Result<TransferStats> {
671    let objects_dir = local_odb.objects_dir();
672    let pack_dir = objects_dir.join("pack");
673    let mut bytes = 0u64;
674    let mut objects_from_new_packs = 0u64;
675    if pack_dir.is_dir() {
676        for entry in fs::read_dir(&pack_dir).map_err(Error::Io)? {
677            let path = entry.map_err(Error::Io)?.path();
678            if path.extension().is_none_or(|e| e != "pack") {
679                continue;
680            }
681            let Some(name) = path.file_name() else {
682                continue;
683            };
684            let name = name.to_string_lossy();
685            if pack_dir_before.contains(name.as_ref()) {
686                continue;
687            }
688            bytes += fs::metadata(&path).map_err(Error::Io)?.len();
689            let idx = path.with_extension("idx");
690            if idx.is_file() {
691                let index = crate::pack::read_pack_index_no_verify(&idx)?;
692                objects_from_new_packs += u64::try_from(index.len()).unwrap_or(u64::MAX);
693            }
694        }
695    }
696    let (loose_objects, loose_bytes) = loose_transfer_stats(objects_dir, installed)?;
697    bytes = bytes.saturating_add(loose_bytes);
698    let installed_count = u64::try_from(installed.len()).unwrap_or(u64::MAX);
699    let objects = if objects_from_new_packs > 0 {
700        objects_from_new_packs.saturating_add(loose_objects)
701    } else {
702        installed_count
703    };
704    let kinds = if objects == installed_count {
705        Some(object_kind_counts_for_oids(local_odb, installed)?)
706    } else {
707        None
708    };
709    Ok(TransferStats {
710        objects,
711        bytes,
712        kinds,
713    })
714}
715
716/// A single ref update requested by a push.
717#[derive(Clone, Debug, Default)]
718pub struct PushRefSpec {
719    /// The source object to push (`None` for a deletion).
720    pub src: Option<ObjectId>,
721    /// Local source ref name when known (for reports and `git push --porcelain`).
722    pub src_ref: Option<String>,
723    /// The destination ref on the remote (e.g. `refs/heads/main`).
724    pub dst: String,
725    /// Whether a non-fast-forward update is allowed.
726    pub force: bool,
727    /// Whether this update deletes the remote ref.
728    pub delete: bool,
729    /// Compare-and-swap expectation: the remote ref's current value must match
730    /// this (force-with-lease). `None` disables the value check.
731    pub expected_old: Option<ObjectId>,
732    /// Force-with-lease expectation that the remote ref does **not** currently
733    /// exist. When `true`, a push whose destination already exists on the remote
734    /// is rejected as stale (used for "create only" pushes whose lease is the
735    /// ref's absence). Independent of [`Self::expected_old`].
736    pub expect_absent: bool,
737}
738
739/// Local ref name carried on a push spec for status reporting.
740#[must_use]
741pub(crate) fn push_spec_local_ref(spec: &PushRefSpec) -> Option<String> {
742    spec.src_ref.clone()
743}
744
745/// Resolve the remote ref name for a push deletion from a short name or full ref.
746///
747/// When `spec` is a short name, a local `refs/heads/<spec>` wins, then an
748/// advertised remote branch, then an advertised remote tag, otherwise
749/// `refs/heads/<spec>` (same default as `git push <remote> :<branch>`).
750#[must_use]
751pub fn resolve_push_delete_ref(
752    git_dir: &Path,
753    remote_ref_names: &HashSet<String>,
754    spec: &str,
755) -> String {
756    if spec.starts_with("refs/") {
757        return spec.to_owned();
758    }
759    let branch = format!("refs/heads/{spec}");
760    let tag = format!("refs/tags/{spec}");
761    if crate::refs::resolve_ref(git_dir, &branch).is_ok() {
762        return branch;
763    }
764    if remote_ref_names.contains(&branch) {
765        return branch;
766    }
767    if crate::refs::resolve_ref(git_dir, &tag).is_ok() {
768        return tag;
769    }
770    if remote_ref_names.contains(&tag) {
771        return tag;
772    }
773    branch
774}
775
776/// Options controlling a push.
777#[derive(Clone, Default)]
778pub struct PushOptions {
779    /// Apply all updates atomically (all-or-nothing).
780    pub atomic: bool,
781    /// Compute results without writing to the remote.
782    pub dry_run: bool,
783    /// When set, update `refs/remotes/<name>/...` in the local repo after a
784    /// successful push (matching Git's post-push tracking ref maintenance).
785    pub tracking_remote: Option<String>,
786    /// Server-side push options to transmit (`git push --push-option <value>`).
787    ///
788    /// When non-empty, the negotiated capability list includes `push-options`
789    /// and one `push-option <value>` pkt-line per entry is written after the
790    /// ref-update command block and before the flush/pack. The remote exposes
791    /// these to its hooks via `GIT_PUSH_OPTION_COUNT` / `GIT_PUSH_OPTION_<n>`.
792    ///
793    /// If this is non-empty but the remote `git-receive-pack` does not advertise
794    /// the `push-options` capability, the push fails with
795    /// [`crate::error::Error::PushOptionsUnsupported`] (matching Git).
796    pub push_options: Vec<String>,
797    /// Diagnostic sink for [`crate::diagnostics::Trace::Network`] events.
798    pub diagnostics: Option<crate::diagnostics::DiagnosticsHandle>,
799    /// When true with [`Self::diagnostics`], push paths may emit network trace events.
800    pub network_trace: bool,
801    /// Ref tips already read from the remote, for example while planning
802    /// ([`PushPlanContext::remote_tips`](crate::remote::push_plan::PushPlanContext)).
803    ///
804    /// A local push uses them as the objects the remote already has instead of listing the
805    /// remote's refs again; ref updates still check each destination's current value. They
806    /// must come from the push destination, or objects the destination lacks may be left out
807    /// of the pack. Wire pushes read the advertisement from their connection and ignore this.
808    pub remote_ref_tips: Option<Vec<ObjectId>>,
809}
810
811/// The structured result of a push. Reuses [`PushRefResult`] for per-ref status.
812#[derive(Clone, Debug, Default)]
813pub struct PushOutcome {
814    /// Per-ref resolved results (status, old/new oid, reason).
815    pub results: Vec<PushRefResult>,
816    /// Hook and diagnostic lines from the remote (side-band channel 2), in order.
817    ///
818    /// Empty for local `file://` pushes and when the server did not use side-band.
819    pub remote_messages: Vec<String>,
820}
821
822pub use crate::pack_objects::{
823    build_pack, build_pack_from_send_list, reachable_closure, PackBuildOptions, PackObjects,
824    PackObjectsOptions, PackStats,
825};
826
827pub(crate) use crate::pack_objects::build_pack_for_local_fetch;
828
829fn pack_build_options_for_send(
830    source_odb: &Odb,
831    oids: &[ObjectId],
832    base: &PackBuildOptions,
833) -> PackBuildOptions {
834    if oids.is_empty() || oids.len() > 512 {
835        return *base;
836    }
837    let commits_tags_only = oids.iter().all(|oid| {
838        source_odb
839            .read(oid)
840            .map(|obj| matches!(obj.kind, ObjectKind::Commit | ObjectKind::Tag))
841            .unwrap_or(false)
842    });
843    if !commits_tags_only {
844        return *base;
845    }
846    let mut fast = *base;
847    fast.delta = false;
848    fast.reuse_deltas = false;
849    fast.thin = false;
850    fast
851}
852
853fn ingest_pack_options_for_fetch(
854    local_git_dir: &Path,
855    fetch_opts: &FetchOptions,
856) -> Result<crate::index_pack::IngestPackOptions> {
857    let pack_config =
858        crate::config::ConfigSet::load(&fetch_opts.environment, Some(local_git_dir), true)?;
859    let index_threads = crate::parallel::index_pack_threads_for_ingest(
860        fetch_opts.thread_policy,
861        &pack_config,
862        fetch_opts.index_pack_threads,
863    )?;
864    Ok(crate::index_pack::IngestPackOptions {
865        fix_thin: true,
866        skip_post_index_verify: true,
867        threads: index_threads,
868        merged_config: Some(pack_config),
869        environment: fetch_opts.environment.clone(),
870        mark_promisor_pack: fetch_opts.mark_received_pack_promisor,
871        trusted_index_rows: None,
872    })
873}
874
875/// Copy objects for a local fetch: link remote packfiles when possible, then pack any remainder.
876#[allow(clippy::too_many_arguments)]
877fn copy_objects_for_local_fetch(
878    local_git_dir: &Path,
879    remote_odb: &Odb,
880    local_odb: &Odb,
881    wants: &[ObjectId],
882    haves: &[ObjectId],
883    source_shallow: &HashSet<ObjectId>,
884    have_shallow: &HashSet<ObjectId>,
885    try_pack_link: bool,
886    pack_build: &PackBuildOptions,
887    fetch_opts: &FetchOptions,
888    progress: &mut dyn ProgressSink,
889    cancel: &dyn crate::progress::Cancel,
890) -> Result<(HashSet<ObjectId>, TransferStats)> {
891    use crate::local_object_copy::{
892        link_loose_objects, link_loose_objects_for_oids, link_remote_pack_files, linked_object_set,
893        refresh_local_odb_after_link, try_satisfy_via_object_link,
894    };
895
896    let remote_objects = remote_odb.objects_dir();
897    let local_objects = local_odb.objects_dir();
898    let pack_dir_before = pack_file_basenames(&local_objects.join("pack"));
899    let remote_has_alternates = !crate::pack::read_alternates_recursive(remote_objects)?.is_empty();
900
901    if !remote_has_alternates
902        && !pack_build.thin
903        && try_pack_link
904        && haves.is_empty()
905        && !wants.is_empty()
906    {
907        progress.start(ProgressPhase::CopyingObjects, Some(wants.len() as u64));
908        crate::progress::poll_cancel(progress, cancel)?;
909        let linked_packs =
910            link_remote_pack_files(remote_objects, local_objects, progress, cancel, wants.len())?;
911        if linked_packs > 0 {
912            refresh_local_odb_after_link(local_odb);
913        }
914        let pack_alone_satisfies_wants =
915            linked_packs > 0 && wants.iter().all(|oid| local_odb.exists(oid));
916        let linked_loose = link_loose_objects(remote_objects, local_objects, progress, cancel)?;
917        if linked_packs > 0 || linked_loose > 0 {
918            refresh_local_odb_after_link(local_odb);
919        }
920        if (linked_packs > 0 || linked_loose > 0) && wants.iter().all(|oid| local_odb.exists(oid)) {
921            if linked_loose == 0 {
922                progress.set(wants.len() as u64);
923            }
924            progress.finish();
925            let stats_loose = if pack_alone_satisfies_wants {
926                HashSet::new()
927            } else {
928                crate::local_object_copy::loose_reachable_from_roots(
929                    local_odb,
930                    local_objects,
931                    wants,
932                    source_shallow,
933                    cancel,
934                )?
935            };
936            let oids = if stats_loose.is_empty() {
937                linked_object_set(local_odb, wants)
938            } else {
939                stats_loose.clone()
940            };
941            let mut transfer =
942                transfer_stats_for_local_operation(local_odb, &stats_loose, &pack_dir_before)?;
943            enrich_local_link_transfer_kinds(
944                local_odb,
945                &pack_dir_before,
946                &stats_loose,
947                &mut transfer,
948            )?;
949            return Ok((oids, transfer));
950        }
951        progress.finish();
952    }
953
954    let have_tips: HashSet<ObjectId> = haves.iter().copied().collect();
955    let send_set = collect_reachable_excluding_peer(
956        remote_odb,
957        wants,
958        local_odb,
959        &have_tips,
960        false,
961        source_shallow,
962    )?;
963    let send = &send_set.objects;
964
965    if !pack_build.thin && try_pack_link {
966        progress.start(ProgressPhase::CopyingObjects, Some(send.len() as u64));
967        crate::progress::poll_cancel(progress, cancel)?;
968        if try_satisfy_via_object_link(
969            remote_objects,
970            local_objects,
971            local_odb,
972            send,
973            false,
974            progress,
975            cancel,
976        )? {
977            progress.set(send.len() as u64);
978            progress.finish();
979            let oids = linked_object_set(local_odb, send);
980            let transfer = transfer_stats_for_local_operation(local_odb, &oids, &pack_dir_before)?;
981            return Ok((oids, transfer));
982        }
983        progress.finish();
984    }
985
986    let mut still_missing = objects_missing_from_peer(local_odb, send);
987    if still_missing.is_empty() {
988        let oids = linked_object_set(local_odb, send);
989        let transfer = transfer_stats_for_local_operation(local_odb, &oids, &pack_dir_before)?;
990        return Ok((oids, transfer));
991    }
992
993    // Hard-link loose objects before building an in-memory pack. Re-reading and
994    // recompressing large loose blobs (typical local clone) would spike RSS to several
995    // times the object size; linking matches system `git clone` on the same filesystem.
996    progress.start(
997        ProgressPhase::CopyingObjects,
998        Some(still_missing.len() as u64),
999    );
1000    let linked_loose = link_loose_objects_for_oids(
1001        remote_objects,
1002        local_objects,
1003        &still_missing,
1004        progress,
1005        cancel,
1006    )?;
1007    if linked_loose > 0 {
1008        refresh_local_odb_after_link(local_odb);
1009        still_missing = local_odb.missing_oids(still_missing)?;
1010    }
1011    progress.finish();
1012    if still_missing.is_empty() {
1013        let oids = linked_object_set(local_odb, send);
1014        let transfer = transfer_stats_for_local_operation(local_odb, &oids, &pack_dir_before)?;
1015        return Ok((oids, transfer));
1016    }
1017
1018    let pack_opts = pack_build_options_for_send(remote_odb, &still_missing, pack_build);
1019    progress.start(
1020        ProgressPhase::CopyingObjects,
1021        Some(still_missing.len() as u64),
1022    );
1023    crate::progress::poll_cancel(progress, cancel)?;
1024    let pack = build_pack_for_local_transfer(
1025        remote_odb,
1026        &send_set,
1027        &still_missing,
1028        local_odb,
1029        haves,
1030        source_shallow,
1031        have_shallow,
1032        &pack_opts,
1033    )?;
1034    crate::progress::poll_cancel(progress, cancel)?;
1035    let ingest_opts = ingest_pack_options_for_fetch(local_git_dir, fetch_opts)?;
1036    let ingested = crate::index_pack::ingest_received_pack_with_progress(
1037        pack,
1038        local_odb,
1039        &ingest_opts,
1040        None,
1041        Some(cancel),
1042    )?;
1043    progress.inc(ingested.object_ids.len() as u64);
1044    progress.finish();
1045    refresh_local_odb_after_link(local_odb);
1046    let mut out = linked_object_set(local_odb, send);
1047    out.extend(ingested.object_ids);
1048    let transfer = transfer_stats_for_local_operation(local_odb, &out, &pack_dir_before)?;
1049    Ok((out, transfer))
1050}
1051
1052/// Copy objects for a local push: link source packfiles into the remote when possible, then pack any remainder.
1053#[allow(clippy::too_many_arguments)]
1054fn copy_objects_for_local_push(
1055    local_odb: &Odb,
1056    remote_odb: &Odb,
1057    wants: &[ObjectId],
1058    haves: &[ObjectId],
1059    source_shallow: &HashSet<ObjectId>,
1060    have_shallow: &HashSet<ObjectId>,
1061    opts: &PackBuildOptions,
1062) -> Result<()> {
1063    use crate::local_object_copy::{
1064        link_loose_objects_for_oids, refresh_local_odb_after_link, try_satisfy_via_object_link,
1065    };
1066
1067    let local_objects = local_odb.objects_dir();
1068    let remote_objects = remote_odb.objects_dir();
1069
1070    let have_tips: HashSet<ObjectId> = haves.iter().copied().collect();
1071    let send_set = collect_reachable_excluding_peer(
1072        local_odb,
1073        wants,
1074        remote_odb,
1075        &have_tips,
1076        false,
1077        source_shallow,
1078    )?;
1079    let send = &send_set.objects;
1080
1081    let mut progress = crate::progress::NullProgress;
1082    let cancel = crate::progress::NeverCancel;
1083    if !opts.thin
1084        && try_satisfy_via_object_link(
1085            local_objects,
1086            remote_objects,
1087            remote_odb,
1088            send,
1089            false,
1090            &mut progress,
1091            &cancel,
1092        )?
1093    {
1094        return Ok(());
1095    }
1096
1097    let mut still_missing = objects_missing_from_peer(remote_odb, send);
1098    if still_missing.is_empty() {
1099        return Ok(());
1100    }
1101
1102    if !haves.is_empty() {
1103        let linked_loose = link_loose_objects_for_oids(
1104            local_objects,
1105            remote_objects,
1106            &still_missing,
1107            &mut progress,
1108            &cancel,
1109        )?;
1110        if linked_loose > 0 {
1111            refresh_local_odb_after_link(remote_odb);
1112            still_missing = remote_odb.missing_oids(still_missing)?;
1113        }
1114        if still_missing.is_empty() {
1115            return Ok(());
1116        }
1117    }
1118
1119    let pack_opts = pack_build_options_for_send(local_odb, &still_missing, opts);
1120    let pack = build_pack_for_local_transfer(
1121        local_odb,
1122        &send_set,
1123        &still_missing,
1124        remote_odb,
1125        haves,
1126        source_shallow,
1127        have_shallow,
1128        &pack_opts,
1129    )?;
1130    crate::index_pack::ingest_received_pack(
1131        pack,
1132        remote_odb,
1133        &crate::index_pack::IngestPackOptions {
1134            fix_thin: pack_opts.thin,
1135            ..Default::default()
1136        },
1137    )?;
1138    refresh_local_odb_after_link(remote_odb);
1139    Ok(())
1140}
1141
1142/// The objects of `send` that `peer_odb` does not store on disk.
1143///
1144/// Uses the on-disk check rather than [`Odb::exists`], which reports the empty
1145/// tree as present even without an object file: the result may be packed
1146/// as-is, and the peer must end up with every object it references.
1147fn objects_missing_from_peer(peer_odb: &Odb, send: &[ObjectId]) -> Vec<ObjectId> {
1148    peer_odb.reprepare_local_packs();
1149    send.iter()
1150        .copied()
1151        .filter(|oid| !peer_odb.exists_materialized_local_without_rescan(oid))
1152        .collect()
1153}
1154
1155/// Build the pack that copies `missing` (a subset of `send_set`) into the peer.
1156///
1157/// With no `haves` and a non-thin pack, `missing` is already the exact set the
1158/// peer lacks (the walk stopped at every object the peer stores), so it is
1159/// packed as-is. Re-enumerating from `missing` instead would walk its whole
1160/// closure and re-send objects just linked into the peer, such as every packed
1161/// object reachable from a loose annotated tag. Otherwise defer to
1162/// [`build_pack_for_local_fetch`], which negotiates against the peer's `haves`.
1163#[allow(clippy::too_many_arguments)]
1164fn build_pack_for_local_transfer(
1165    source_odb: &Odb,
1166    send_set: &PeerSendSet,
1167    missing: &[ObjectId],
1168    peer_odb: &Odb,
1169    haves: &[ObjectId],
1170    source_shallow: &HashSet<ObjectId>,
1171    have_shallow: &HashSet<ObjectId>,
1172    opts: &PackBuildOptions,
1173) -> Result<Vec<u8>> {
1174    if haves.is_empty() && !opts.thin {
1175        return crate::pack_objects::build_pack_from_send_list(
1176            source_odb,
1177            missing,
1178            &HashSet::new(),
1179            opts,
1180            &send_set.name_hashes,
1181        );
1182    }
1183    build_pack_for_local_fetch(
1184        source_odb,
1185        missing,
1186        peer_odb,
1187        haves,
1188        source_shallow,
1189        have_shallow,
1190        opts,
1191    )
1192}
1193
1194/// Objects a local transfer must send, from [`collect_reachable_excluding_peer`].
1195struct PeerSendSet {
1196    /// Objects missing from the peer, in walk order.
1197    objects: Vec<ObjectId>,
1198    /// Pack name hash of each blob's and subtree's path, for delta-window ordering.
1199    name_hashes: HashMap<ObjectId, u32>,
1200}
1201
1202/// Like [`collect_reachable_excluding`], but treats every object already stored in
1203/// `peer_odb` as a boundary (plus explicit `peer_boundary` tips). Used for local
1204/// file fetch/push so incremental transfers do not walk full history from `haves`.
1205fn collect_reachable_excluding_peer(
1206    source_odb: &Odb,
1207    roots: &[ObjectId],
1208    peer_odb: &Odb,
1209    peer_boundary: &HashSet<ObjectId>,
1210    skip_missing: bool,
1211    shallow_grafts: &HashSet<ObjectId>,
1212) -> Result<PeerSendSet> {
1213    use crate::pack_name_hash::pack_name_hash_extend;
1214
1215    let mut visited: HashSet<ObjectId> = HashSet::new();
1216    let mut ordered: Vec<ObjectId> = Vec::new();
1217    let mut name_hashes: HashMap<ObjectId, u32> = HashMap::new();
1218    // Each queued object carries the name hash of its directory prefix ("dir/").
1219    let mut queue: VecDeque<(ObjectId, u32)> = VecDeque::new();
1220
1221    // The peer is not written during the walk, so one pack-directory refresh is
1222    // enough; a stale miss only means sending an object the peer already has.
1223    peer_odb.reprepare_local_packs();
1224    let at_boundary = |oid: ObjectId| {
1225        peer_boundary.contains(&oid) || peer_odb.exists_materialized_local_without_rescan(&oid)
1226    };
1227
1228    // `visited` also remembers boundary objects so each object is probed once.
1229    // Blobs named by tree entries are recorded without being read: they have no
1230    // outgoing links, and a missing one still fails when the pack is written.
1231    let enqueue = |oid: ObjectId,
1232                   prefix_hash: Option<u32>,
1233                   queue: &mut VecDeque<(ObjectId, u32)>,
1234                   visited: &mut HashSet<ObjectId>,
1235                   ordered: &mut Vec<ObjectId>|
1236     -> bool {
1237        if !visited.insert(oid) || at_boundary(oid) {
1238            return false;
1239        }
1240        ordered.push(oid);
1241        if let Some(prefix_hash) = prefix_hash {
1242            queue.push_back((oid, prefix_hash));
1243        }
1244        true
1245    };
1246
1247    for &root in roots {
1248        enqueue(root, Some(0), &mut queue, &mut visited, &mut ordered);
1249    }
1250
1251    while let Some((oid, prefix_hash)) = queue.pop_front() {
1252        let obj = match source_odb.read(&oid) {
1253            Ok(o) => o,
1254            Err(_) if skip_missing => continue,
1255            Err(e) => return Err(e),
1256        };
1257        match obj.kind {
1258            ObjectKind::Commit => {
1259                let commit = parse_commit(&obj.data)?;
1260                if !shallow_grafts.contains(&oid) {
1261                    for parent in commit.parents {
1262                        enqueue(parent, Some(0), &mut queue, &mut visited, &mut ordered);
1263                    }
1264                }
1265                enqueue(commit.tree, Some(0), &mut queue, &mut visited, &mut ordered);
1266            }
1267            ObjectKind::Tree => {
1268                for entry in parse_tree(&obj.data)? {
1269                    if entry.mode == 0o160000 {
1270                        continue;
1271                    }
1272                    let path_hash = pack_name_hash_extend(prefix_hash, &entry.name);
1273                    let queued = if entry.mode & 0o170000 == 0o040000 {
1274                        let dir_hash = pack_name_hash_extend(path_hash, b"/");
1275                        enqueue(
1276                            entry.oid,
1277                            Some(dir_hash),
1278                            &mut queue,
1279                            &mut visited,
1280                            &mut ordered,
1281                        )
1282                    } else {
1283                        enqueue(entry.oid, None, &mut queue, &mut visited, &mut ordered)
1284                    };
1285                    if queued {
1286                        name_hashes.insert(entry.oid, path_hash);
1287                    }
1288                }
1289            }
1290            ObjectKind::Tag => {
1291                let tag = parse_tag(&obj.data)?;
1292                enqueue(
1293                    tag.object,
1294                    Some(prefix_hash),
1295                    &mut queue,
1296                    &mut visited,
1297                    &mut ordered,
1298                );
1299            }
1300            ObjectKind::Blob => {}
1301        }
1302    }
1303
1304    Ok(PeerSendSet {
1305        objects: ordered,
1306        name_hashes,
1307    })
1308}
1309
1310/// `fetch_local` function.
1311pub fn fetch_local(
1312    local_git_dir: &Path,
1313    remote_git_dir: &Path,
1314    opts: &FetchOptions,
1315) -> Result<FetchOutcome> {
1316    fetch_local_with_progress(
1317        local_git_dir,
1318        remote_git_dir,
1319        opts,
1320        &mut NoProgress,
1321        &crate::progress::NeverCancel,
1322    )
1323}
1324
1325/// Result of matching remote refs to fetch refspecs.
1326pub(crate) struct FetchMatchSet {
1327    pub(crate) matched: Vec<MatchedRef>,
1328    pub(crate) positive: Vec<RefspecItem>,
1329    pub(crate) negatives: Vec<RefspecItem>,
1330    pub(crate) explicit_refspec_fetch: bool,
1331}
1332
1333/// Match remote refs to fetch refspecs (shared by local, wire, and bundle fetch).
1334pub(crate) fn build_fetch_matched_set(
1335    opts: &FetchOptions,
1336    remote_refs: &[(String, ObjectId)],
1337    remote_head_oid: Option<ObjectId>,
1338) -> Result<FetchMatchSet> {
1339    use crate::fetch_request::{FetchRefspecInput, FetchRefspecPlan, FetchRemoteAdvertisement};
1340
1341    let remote = FetchRemoteAdvertisement {
1342        refs: remote_refs,
1343        head_oid: remote_head_oid,
1344    };
1345    let plan = FetchRefspecPlan::resolve(
1346        &FetchRefspecInput {
1347            user_refspecs: opts.refspecs.clone(),
1348            configured_refspecs: opts.configured_refspecs.clone(),
1349            remote_name: opts.remote_name.clone(),
1350            allow_missing_explicit_for_prune: opts.prune,
1351        },
1352        remote,
1353    )?;
1354    let explicit_refspec_fetch = plan.is_explicit();
1355
1356    let mut negatives = match &plan {
1357        FetchRefspecPlan::Pattern { negatives, .. } | FetchRefspecPlan::Mixed { negatives, .. } => {
1358            negatives.clone()
1359        }
1360    };
1361    for spec in &opts.negative_refspecs {
1362        let item = parse_fetch_refspec(spec)
1363            .map_err(|e| Error::Message(format!("invalid negative refspec '{spec}': {e}")))?;
1364        negatives.push(item);
1365    }
1366
1367    let mut positive = match &plan {
1368        FetchRefspecPlan::Pattern { positive, .. } => positive.clone(),
1369        FetchRefspecPlan::Mixed {
1370            pattern_positive, ..
1371        } => {
1372            let mut positive = pattern_positive.clone();
1373            for spec in &opts.refspecs {
1374                if let Ok(item) = parse_fetch_refspec(spec) {
1375                    if !item.negative && !item.pattern && !item.matching {
1376                        positive.push(item);
1377                    }
1378                }
1379            }
1380            positive
1381        }
1382    };
1383    if opts.prune {
1384        crate::fetch_request::supplement_positive_for_explicit_prune(
1385            &mut positive,
1386            &opts.configured_refspecs,
1387            opts.remote_name.as_deref(),
1388        );
1389    }
1390
1391    let matched =
1392        crate::fetch_request::matched_refs_with_negatives(&plan, remote, &negatives, opts.prune)?;
1393    Ok(FetchMatchSet {
1394        matched,
1395        positive,
1396        negatives,
1397        explicit_refspec_fetch,
1398    })
1399}
1400
1401/// Enforce Git fetch ref-update rules for explicit refspec fetches.
1402pub(crate) fn enforce_fetch_ref_update(
1403    local_ref: &str,
1404    old: Option<ObjectId>,
1405    new: ObjectId,
1406    mode: UpdateMode,
1407    opts: &FetchOptions,
1408    explicit_refspec_fetch: bool,
1409    local_repo: Option<&crate::repo::Repository>,
1410) -> Result<()> {
1411    if opts.dry_run {
1412        return Ok(());
1413    }
1414    let would_write = matches!(
1415        mode,
1416        UpdateMode::New | UpdateMode::FastForward | UpdateMode::Forced
1417    );
1418    if let Some(short) = local_ref.strip_prefix("refs/heads/") {
1419        if old.is_some()
1420            && old != Some(new)
1421            && (would_write || mode == UpdateMode::NonFastForwardRejected)
1422        {
1423            if let Some(repo) = local_repo {
1424                if crate::worktree::find_worktree_with_branch_checked_out(repo, short, None)?
1425                    .is_some()
1426                {
1427                    return Err(Error::FetchIntoCheckedOutBranch {
1428                        branch: short.to_owned(),
1429                    });
1430                }
1431            }
1432        }
1433    }
1434    let _ = explicit_refspec_fetch;
1435    Ok(())
1436}
1437
1438/// Fail with [`Error::RemoteUnreadable`] unless `remote_git_dir` is a Git directory.
1439///
1440/// A missing or non-repository path must not read as an empty remote.
1441pub(crate) fn ensure_local_remote_repository(remote_git_dir: &Path) -> Result<()> {
1442    if crate::submodule_gitdir::is_valid_git_dir(remote_git_dir) {
1443        return Ok(());
1444    }
1445    Err(Error::remote_unreadable(
1446        remote_git_dir.display().to_string(),
1447        RemoteUnreadableReason::RepositoryNotFound,
1448    ))
1449}
1450
1451/// Like [`fetch_local`], reporting object copy progress through `progress`.
1452///
1453/// The local repository is opened with [`FetchOptions::environment`], so objects land in its
1454/// `GIT_OBJECT_DIRECTORY` when that is set.
1455pub fn fetch_local_with_progress(
1456    local_git_dir: &Path,
1457    remote_git_dir: &Path,
1458    opts: &FetchOptions,
1459    progress: &mut dyn Progress,
1460    cancel: &dyn crate::progress::Cancel,
1461) -> Result<FetchOutcome> {
1462    crate::progress::poll_cancel(progress, cancel)?;
1463    ensure_local_remote_repository(remote_git_dir)?;
1464    let local = crate::fetch::open_fetch_target(local_git_dir, opts)?;
1465    fetch_local_into(&local, remote_git_dir, opts, progress, cancel)
1466}
1467
1468/// Like [`fetch_local_with_progress`], copying objects into `local`'s object store.
1469pub(crate) fn fetch_local_into(
1470    local: &crate::repo::Repository,
1471    remote_git_dir: &Path,
1472    opts: &FetchOptions,
1473    progress: &mut dyn Progress,
1474    cancel: &dyn crate::progress::Cancel,
1475) -> Result<FetchOutcome> {
1476    crate::progress::poll_cancel(progress, cancel)?;
1477    ensure_local_remote_repository(remote_git_dir)?;
1478    let local_git_dir = local.git_dir();
1479    validate_fetch_depth_local(local_git_dir, opts)?;
1480
1481    let remote_odb = open_odb(remote_git_dir);
1482    let remote_algo = remote_odb.hash_algo();
1483    let reopened = sync_empty_destination_object_format(local_git_dir, remote_algo, opts)?
1484        .then(|| reopen_after_format_change(local))
1485        .transpose()?;
1486    let local = reopened.as_ref().unwrap_or(local);
1487    let local_git_dir = local.git_dir();
1488    let local_odb = local.odb();
1489
1490    // 1. Enumerate remote refs (with HEAD symref for the default branch).
1491    let remote_entries = crate::remote::list_refs_from_git_dir_for_fetch(
1492        remote_git_dir,
1493        Some(&remote_odb),
1494        &opts.refspecs,
1495    )
1496    .map_err(crate::error::Error::from)?;
1497
1498    let mut default_branch = None;
1499    let mut remote_head_oid = None;
1500    // remote ref name -> oid (excluding HEAD and peeled `^{}` entries).
1501    let mut remote_refs: Vec<(String, ObjectId)> = Vec::new();
1502    for entry in &remote_entries {
1503        if entry.name == "HEAD" {
1504            remote_head_oid = Some(entry.oid);
1505            default_branch = entry
1506                .symref_target
1507                .as_ref()
1508                .map(|t| t.strip_prefix("refs/heads/").unwrap_or(t).to_owned());
1509            continue;
1510        }
1511        if entry.name.ends_with("^{}") {
1512            continue;
1513        }
1514        if !crate::refs::is_valid_fetch_advertised_ref(&entry.name) {
1515            continue;
1516        }
1517        remote_refs.push((entry.name.clone(), entry.oid));
1518    }
1519
1520    let FetchMatchSet {
1521        mut matched,
1522        positive,
1523        negatives,
1524        explicit_refspec_fetch,
1525    } = build_fetch_matched_set(opts, &remote_refs, remote_head_oid)?;
1526    let mut matched_oids: HashSet<ObjectId> = matched.iter().map(|m| m.oid).collect();
1527    let mut seen_remote_ref: HashSet<String> =
1528        matched.iter().map(|m| m.remote_ref.clone()).collect();
1529
1530    let local_shallow = crate::shallow::load_shallow_boundaries(local_git_dir);
1531    let remote_shallow = crate::shallow::load_shallow_boundaries(remote_git_dir);
1532
1533    // Explicitly requested tags participate in depth calculation; automatically
1534    // followed tags must only use the history selected by those fetch roots.
1535    if opts.tags == TagMode::All {
1536        apply_tag_mode(
1537            opts.tags,
1538            &remote_refs,
1539            &remote_odb,
1540            &remote_shallow,
1541            &negatives,
1542            &HashSet::new(),
1543            &mut matched,
1544            &mut matched_oids,
1545            &mut seen_remote_ref,
1546        )?;
1547    }
1548    let shallow_response = if opts.has_deepen_request() {
1549        let shallow_wants: HashSet<ObjectId> = matched
1550            .iter()
1551            .map(|m| shallow_want_commit_tip(&remote_odb, m))
1552            .collect::<Result<_>>()?;
1553        let remote_repo =
1554            crate::repo::Repository::open(remote_git_dir, &RepositoryOptions::empty())?;
1555        let (wire_depth, deepen_relative) = opts
1556            .wire_deepen()
1557            .map(|(d, r)| (Some(d), r))
1558            .unwrap_or((None, false));
1559        let req = crate::serve::ShallowRequest {
1560            client_shallow: local_shallow.iter().copied().collect(),
1561            depth: wire_depth,
1562            deepen_since: opts
1563                .deepen_since
1564                .as_deref()
1565                .and_then(|s| s.parse::<i64>().ok()),
1566            deepen_not: Vec::new(),
1567            deepen_relative,
1568        };
1569        Some(crate::serve::compute_shallow_response(
1570            &remote_repo,
1571            &shallow_wants.into_iter().collect::<Vec<_>>(),
1572            &req,
1573        )?)
1574    } else {
1575        None
1576    };
1577    let copy_source_shallow = shallow_response
1578        .as_ref()
1579        .map(|resp| resp.pack_shallow_grafts.clone())
1580        .unwrap_or_else(|| remote_shallow.clone());
1581    let mut tag_shallow = copy_source_shallow.clone();
1582    if shallow_response.is_none() {
1583        tag_shallow.extend(local_shallow.iter().copied());
1584    }
1585
1586    let following_only = if opts.tags == TagMode::Following {
1587        let tags_present = crate::fetch::tags_already_local(local.refs(), &remote_refs);
1588        apply_tag_mode(
1589            opts.tags,
1590            &remote_refs,
1591            &remote_odb,
1592            &tag_shallow,
1593            &negatives,
1594            &tags_present,
1595            &mut matched,
1596            &mut matched_oids,
1597            &mut seen_remote_ref,
1598        )?
1599    } else {
1600        HashSet::new()
1601    };
1602
1603    let mut following_tag_wants = if shallow_response.is_some() {
1604        // Branch roots already bring in lightweight targets. Only annotated tag
1605        // objects (and their chains) need additional wants in this response.
1606        local_odb
1607            .missing_oids(
1608                matched
1609                    .iter()
1610                    .filter(|m| {
1611                        following_only.contains(&m.oid)
1612                            && m.advertised_peel.is_some_and(|peel| peel != m.oid)
1613                    })
1614                    .map(|m| m.oid),
1615            )?
1616            .into_iter()
1617            .collect()
1618    } else {
1619        crate::fetch::following_tag_wants_with_local_peel(&matched, &following_only, local_odb)?
1620    };
1621    let shallow_request = opts.has_deepen_request() || !local_shallow.is_empty();
1622    if shallow_request && shallow_response.is_none() {
1623        let fetch_commit_oids = matched
1624            .iter()
1625            .filter(|m| !m.is_tag)
1626            .map(|m| m.oid)
1627            .collect();
1628        let peel_set = crate::fetch::following_peel_commit_set_for_shallow(
1629            &remote_odb,
1630            &fetch_commit_oids,
1631            opts.absolute_depth_for_tags(),
1632            &tag_shallow,
1633        )?;
1634        following_tag_wants.extend(
1635            crate::fetch::following_tag_wants_peel_among_fetched_commits(
1636                &matched,
1637                &following_only,
1638                &peel_set,
1639            ),
1640        );
1641    }
1642
1643    // Determine wants and copy only the required object set.
1644    let mut wants: Vec<ObjectId> = if shallow_request {
1645        matched_oids
1646            .iter()
1647            .copied()
1648            .filter(|oid| !following_only.contains(oid) || following_tag_wants.contains(oid))
1649            .collect()
1650    } else {
1651        local_odb.missing_oids(matched_oids.iter().copied())?
1652    };
1653    let (shallow_new, shallow_unshallow) = if let Some(resp) = shallow_response {
1654        for oid in resp.extra_wants.iter().chain(resp.shallow.iter()).copied() {
1655            if !wants.contains(&oid) {
1656                wants.push(oid);
1657            }
1658        }
1659        (resp.shallow, resp.unshallow)
1660    } else {
1661        (Vec::new(), Vec::new())
1662    };
1663    let mut pack_oids: HashSet<ObjectId> = HashSet::new();
1664    let mut transfer = TransferStats::default();
1665    if !wants.is_empty() && !opts.dry_run {
1666        // Resolve exclusion tips only once the final shallow/deepen wants need a copy.
1667        let mut haves: Vec<ObjectId> = Vec::new();
1668        let mut have_seen: HashSet<ObjectId> = HashSet::new();
1669        for m in &matched {
1670            if let Some(local_ref) = &m.local_ref {
1671                if let Ok(old) = local.resolve_ref_oid(local_ref) {
1672                    if have_seen.insert(old) {
1673                        haves.push(old);
1674                    }
1675                }
1676            }
1677        }
1678
1679        let remote_cfg = config_for_git_dir(remote_git_dir);
1680        let pack_opts = PackBuildOptions::for_local_copy(remote_cfg.as_ref());
1681        let (oids, fetch_transfer) = copy_objects_for_local_fetch(
1682            local_git_dir,
1683            &remote_odb,
1684            local_odb,
1685            &wants,
1686            &haves,
1687            &copy_source_shallow,
1688            &local_shallow,
1689            !opts.has_deepen_request(),
1690            &pack_opts,
1691            opts,
1692            progress,
1693            cancel,
1694        )?;
1695        pack_oids = oids;
1696        transfer = fetch_transfer;
1697    }
1698
1699    if opts.initial_remote_fetch
1700        && !remote_shallow.is_empty()
1701        && !opts.has_deepen_request()
1702        && !opts.dry_run
1703    {
1704        let remote_shallow_vec: Vec<ObjectId> = remote_shallow.iter().copied().collect();
1705        crate::shallow::apply_shallow_updates(local_git_dir, &remote_shallow_vec, &[])?;
1706    }
1707    if (!shallow_new.is_empty() || !shallow_unshallow.is_empty()) && !opts.dry_run {
1708        crate::shallow::apply_shallow_updates(local_git_dir, &shallow_new, &shallow_unshallow)?;
1709    }
1710
1711    if opts.tags == TagMode::Following {
1712        let boundaries = crate::shallow::load_shallow_boundaries(local_git_dir);
1713        crate::fetch::retain_following_tags(local_odb, &mut matched, &pack_oids, &boundaries)?;
1714    }
1715
1716    // 4. Classify and apply ref updates. Ancestry checks use the local repo
1717    //    (or the bare remote during `--dry-run`, when objects are not copied).
1718    let dry_run_remote = if opts.dry_run {
1719        crate::repo::Repository::open(remote_git_dir, &RepositoryOptions::empty()).ok()
1720    } else {
1721        None
1722    };
1723    let classify_repo = if opts.dry_run {
1724        dry_run_remote.as_ref()
1725    } else {
1726        Some(local)
1727    };
1728
1729    let mut updates: Vec<RefUpdate> = Vec::new();
1730
1731    // Prune BEFORE writing the new tips. A stale tracking ref stored as a file
1732    // (e.g. `refs/remotes/origin/a`) otherwise blocks creating a nested ref the
1733    // same fetch introduces (`refs/remotes/origin/a/b`) with a "File exists"
1734    // directory/file conflict (matches `git fetch --prune` ordering).
1735    let mut prune_batch: Vec<crate::refs::store::RefUpdate> = Vec::new();
1736    if opts.prune {
1737        prune_tracking_refs(
1738            local_git_dir,
1739            &positive,
1740            &remote_refs,
1741            opts.dry_run,
1742            &mut updates,
1743            if opts.dry_run {
1744                None
1745            } else {
1746                Some(&mut prune_batch)
1747            },
1748        )?;
1749    }
1750    if !opts.dry_run && !prune_batch.is_empty() {
1751        crate::refs::commit_ref_store_batch(local_git_dir, &prune_batch)?;
1752    }
1753
1754    let mut store_batch: Vec<crate::refs::store::RefUpdate> = Vec::new();
1755    for m in &matched {
1756        let Some(local_ref) = &m.local_ref else {
1757            // dst empty: fetched but not stored. Report as a no-store update.
1758            updates.push(RefUpdate {
1759                remote_ref: m.remote_ref.clone(),
1760                local_ref: None,
1761                old_oid: None,
1762                new_oid: Some(m.oid),
1763                mode: UpdateMode::NoChangeNeeded,
1764                note: Some("not stored (empty destination)".to_owned()),
1765            });
1766            continue;
1767        };
1768
1769        let old = local.resolve_ref_oid(local_ref).ok();
1770        let mode = classify_update(
1771            old.as_ref(),
1772            &m.oid,
1773            m.force,
1774            m.dest_is_tag,
1775            classify_repo,
1776            None,
1777        );
1778
1779        let write = matches!(
1780            mode,
1781            UpdateMode::New | UpdateMode::FastForward | UpdateMode::Forced
1782        );
1783        enforce_fetch_ref_update(
1784            local_ref,
1785            old,
1786            m.oid,
1787            mode,
1788            opts,
1789            explicit_refspec_fetch,
1790            classify_repo,
1791        )?;
1792        if write && !opts.dry_run {
1793            if !crate::refs::is_valid_storable_ref_name(local_ref) {
1794                updates.push(RefUpdate {
1795                    remote_ref: m.remote_ref.clone(),
1796                    local_ref: Some(local_ref.clone()),
1797                    old_oid: old,
1798                    new_oid: Some(m.oid),
1799                    mode,
1800                    note: Some("skipped (invalid ref name)".to_owned()),
1801                });
1802                continue;
1803            }
1804            if m.dest_is_tag && !ref_target_exists(local_odb, &remote_odb, m.oid)? {
1805                updates.push(RefUpdate {
1806                    remote_ref: m.remote_ref.clone(),
1807                    local_ref: Some(local_ref.clone()),
1808                    old_oid: old,
1809                    new_oid: Some(m.oid),
1810                    mode: UpdateMode::NoChangeNeeded,
1811                    note: Some("skipped (tag target not present locally)".to_owned()),
1812                });
1813                continue;
1814            }
1815            store_batch.push(crate::refs::store::RefUpdate {
1816                name: local_ref.clone(),
1817                new_value: Some(crate::refs::store::RawRef::Direct(m.oid)),
1818                expected: crate::refs::store::Expected::Any,
1819                reflog: None,
1820                flags: Default::default(),
1821            });
1822        }
1823
1824        updates.push(RefUpdate {
1825            remote_ref: m.remote_ref.clone(),
1826            local_ref: Some(local_ref.clone()),
1827            old_oid: old,
1828            new_oid: Some(m.oid),
1829            mode,
1830            note: None,
1831        });
1832    }
1833
1834    if let Some(remote) = opts.remote_name.as_deref() {
1835        if !opts.dry_run {
1836            if let Some(branch) = default_branch.as_deref() {
1837                let head_ref = format!("refs/remotes/{remote}/HEAD");
1838                if crate::refs::resolve_ref(local_git_dir, &head_ref).is_err() {
1839                    let target = format!("refs/remotes/{remote}/{branch}");
1840                    let target_will_exist = store_batch.iter().any(|u| u.name == target)
1841                        || crate::refs::resolve_ref(local_git_dir, &target).is_ok();
1842                    if target_will_exist {
1843                        store_batch.push(crate::refs::store::RefUpdate {
1844                            name: head_ref,
1845                            new_value: Some(crate::refs::store::RawRef::Symbolic(target)),
1846                            expected: crate::refs::store::Expected::Any,
1847                            reflog: None,
1848                            flags: Default::default(),
1849                        });
1850                    }
1851                }
1852            }
1853        }
1854    }
1855    if !opts.dry_run && !store_batch.is_empty() {
1856        apply_fetch_ref_updates(
1857            local_git_dir,
1858            &store_batch,
1859            &mut updates,
1860            FetchRefRejectHint {
1861                prune: opts.prune,
1862                positive: &positive,
1863                remote_refs: &remote_refs,
1864                remote_name: opts.remote_name.as_deref(),
1865            },
1866        )?;
1867    }
1868
1869    crate::fetch::finish_initial_remote_fetch_layout(
1870        local_git_dir,
1871        opts,
1872        default_branch.as_deref(),
1873    )?;
1874    if let Some(remote) = opts.remote_name.as_deref() {
1875        crate::fetch::ensure_remote_tracking_head_symref(
1876            local_git_dir,
1877            remote,
1878            default_branch.as_deref(),
1879            opts.dry_run,
1880        )?;
1881    }
1882    finish_anonymous_url_fetch(
1883        local_git_dir,
1884        opts,
1885        explicit_refspec_fetch,
1886        default_branch.as_deref(),
1887        &remote_refs,
1888        &matched,
1889    )?;
1890    let new_shallow = crate::shallow::dedupe_shallow_list(if opts.has_deepen_request() {
1891        shallow_new
1892    } else if opts.initial_remote_fetch {
1893        remote_shallow.iter().copied().collect()
1894    } else {
1895        Vec::new()
1896    });
1897    Ok(FetchOutcome {
1898        updates,
1899        default_branch,
1900        new_shallow,
1901        new_unshallow: shallow_unshallow,
1902        transfer,
1903    })
1904}
1905
1906/// Push refs and objects from one on-disk git repository into another, entirely
1907/// in-process (no subprocess, no wire protocol).
1908///
1909/// This is the local / `file://` push (send-pack) counterpart to
1910/// [`fetch_local`]. For each [`PushRefSpec`] it:
1911///
1912/// 1. Resolves the source oid from the LOCAL repo (for a non-delete update) and
1913///    reads the remote's current value of `dst`.
1914/// 2. Enforces the update rules and produces a [`PushRefResult`] with the right
1915///    [`crate::push_report::PushRefStatus`]:
1916///    * `expected_old` set and mismatching the remote's current value →
1917///      [`PushRefStatus::RejectStale`] (compare-and-swap / force-with-lease).
1918///    * deletion → succeed when present, or [`PushRefStatus::UpToDate`] when the
1919///      ref is already gone.
1920///    * non-fast-forward (remote current is not an ancestor of the source)
1921///      without `force` → [`PushRefStatus::RejectNonFastForward`]; with `force`
1922///      it is accepted and reported as forced.
1923///    * unchanged (remote already at the source) → [`PushRefStatus::UpToDate`].
1924///    * otherwise [`PushRefStatus::Ok`].
1925/// 3. For accepted non-delete updates, copies the minimal object closure from the
1926///    LOCAL odb into the REMOTE odb via [`build_pack`] and
1927///    [`crate::index_pack::ingest_received_pack`], excluding objects already
1928///    reachable from the remote's existing ref tips.
1929/// 4. Applies the ref change on the remote (unless `opts.dry_run`).
1930///
1931/// When `opts.atomic` is set and any ref is rejected, no ref or object is
1932/// written and every otherwise-accepted ref is reported as
1933/// [`PushRefStatus::AtomicPushFailed`].
1934///
1935/// Both repositories must use the same object hash algorithm; the hash width is
1936/// threaded through [`Odb::hash_algo`] so SHA-256 repos work.
1937///
1938/// # Errors
1939///
1940/// Returns an error if either repository cannot be opened, if a source object is
1941/// missing from the local odb, or on I/O failure while writing objects or refs.
1942//
1943// TODO(phase: remote transports): `git://`, `http(s)`, and `ssh` push
1944// (receive-pack handshake + report-status parsing + credential helpers) are out
1945// of scope here and live in a later phase.
1946pub fn push_local(
1947    local_git_dir: &Path,
1948    remote_git_dir: &Path,
1949    refs: &[PushRefSpec],
1950    opts: &PushOptions,
1951    cancel: &dyn crate::progress::Cancel,
1952) -> Result<PushOutcome> {
1953    let local = crate::repo::Repository::open(local_git_dir, &RepositoryOptions::empty())?;
1954    push_local_from(&local, remote_git_dir, refs, opts, cancel)
1955}
1956
1957/// Like [`push_local`], reading the objects to send through `local`'s object store.
1958pub(crate) fn push_local_from(
1959    local: &crate::repo::Repository,
1960    remote_git_dir: &Path,
1961    refs: &[PushRefSpec],
1962    opts: &PushOptions,
1963    cancel: &dyn crate::progress::Cancel,
1964) -> Result<PushOutcome> {
1965    let local_git_dir = local.git_dir();
1966    let mut progress_sink = crate::progress::NullProgress;
1967    crate::progress::poll_cancel(&mut progress_sink, cancel)?;
1968    if !opts.push_options.is_empty() {
1969        return Err(Error::PushOptionsUnsupported);
1970    }
1971    ensure_local_remote_repository(remote_git_dir)?;
1972
1973    let local_odb = local.odb();
1974    let remote_odb = open_odb(remote_git_dir);
1975
1976    // The remote's existing ref tips become the `haves` for pack building, so the
1977    // copied object closure excludes everything the remote already has.
1978    let remote_have_tips: Vec<ObjectId> = match &opts.remote_ref_tips {
1979        Some(tips) => tips.clone(),
1980        None => crate::refs::list_refs(remote_git_dir, "refs/")?
1981            .into_iter()
1982            .map(|(_, oid)| oid)
1983            .collect(),
1984    };
1985
1986    // First pass: decide each ref's status without mutating anything.
1987    let mut decisions: Vec<PushDecision> = Vec::with_capacity(refs.len());
1988    for spec in refs {
1989        decisions.push(decide_push(spec, local_odb, remote_git_dir, Some(local))?);
1990    }
1991
1992    // Atomic: if any update would be rejected, apply none and demote the
1993    // otherwise-accepted updates to AtomicPushFailed.
1994    let any_rejected = decisions.iter().any(|d| d.result.status.is_error());
1995    if opts.atomic && any_rejected {
1996        for d in &mut decisions {
1997            if matches!(d.result.status, PushRefStatus::Ok) {
1998                d.result.status = PushRefStatus::AtomicPushFailed;
1999                d.apply = false;
2000            }
2001        }
2002        return Ok(PushOutcome {
2003            results: decisions.into_iter().map(|d| d.result).collect(),
2004            remote_messages: Vec::new(),
2005        });
2006    }
2007
2008    let push_wants: Vec<ObjectId> = decisions
2009        .iter()
2010        .filter(|d| d.apply && !opts.dry_run)
2011        .filter_map(|d| match &d.action {
2012            PushAction::Update(src) => Some(*src),
2013            _ => None,
2014        })
2015        .collect();
2016    if !push_wants.is_empty() && !opts.dry_run {
2017        crate::progress::poll_cancel(&mut progress_sink, cancel)?;
2018        let local_cfg = config_for_git_dir(local_git_dir);
2019        let pack_opts = if remote_have_tips.is_empty() {
2020            PackBuildOptions::for_local_copy(local_cfg.as_ref())
2021        } else {
2022            PackBuildOptions::for_local_push(local_cfg.as_ref())
2023        };
2024        let empty_shallow = HashSet::new();
2025        let remote_shallow = crate::shallow::load_shallow_boundaries(remote_git_dir);
2026        copy_objects_for_local_push(
2027            local_odb,
2028            &remote_odb,
2029            &push_wants,
2030            &remote_have_tips,
2031            &empty_shallow,
2032            &remote_shallow,
2033            &pack_opts,
2034        )?;
2035    }
2036
2037    // Second pass: apply ref updates (one transaction per ref unless `atomic`).
2038    let mut ref_batch: Vec<crate::refs::store::RefUpdate> = Vec::new();
2039    for d in &mut decisions {
2040        if !d.apply || opts.dry_run {
2041            continue;
2042        }
2043        match &d.action {
2044            PushAction::Update(src) => {
2045                let update = push_store_ref_update(
2046                    d.result.remote_ref.as_str(),
2047                    Some(*src),
2048                    d.result.old_oid,
2049                );
2050                if opts.atomic {
2051                    ref_batch.push(update);
2052                } else if let Err(err) = crate::refs::commit_store_update(remote_git_dir, update) {
2053                    apply_ref_store_error_to_push_decision(d, &err, false);
2054                }
2055            }
2056            PushAction::Delete => {
2057                let update =
2058                    push_store_ref_update(d.result.remote_ref.as_str(), None, d.result.old_oid);
2059                if opts.atomic {
2060                    ref_batch.push(update);
2061                } else if let Err(err) = crate::refs::commit_store_update(remote_git_dir, update) {
2062                    apply_ref_store_error_to_push_decision(d, &err, false);
2063                }
2064            }
2065            PushAction::None => {}
2066        }
2067    }
2068    if opts.atomic && !opts.dry_run && !ref_batch.is_empty() {
2069        if let Err(err) = crate::refs::commit_ref_store_batch(remote_git_dir, &ref_batch) {
2070            apply_atomic_batch_ref_store_error(&mut decisions, &ref_batch, err);
2071        }
2072    }
2073
2074    let results: Vec<_> = decisions.into_iter().map(|d| d.result).collect();
2075    if !opts.dry_run {
2076        if let Some(remote) = opts.tracking_remote.as_deref() {
2077            crate::branch_tracking::apply_push_remote_tracking_updates(
2078                local_git_dir,
2079                remote,
2080                &results,
2081            )?;
2082        }
2083    }
2084
2085    Ok(PushOutcome {
2086        results,
2087        remote_messages: Vec::new(),
2088    })
2089}
2090
2091fn push_store_ref_update(
2092    refname: &str,
2093    new_oid: Option<ObjectId>,
2094    expected_old: Option<ObjectId>,
2095) -> crate::refs::store::RefUpdate {
2096    crate::refs::store::RefUpdate {
2097        name: refname.to_owned(),
2098        new_value: new_oid.map(crate::refs::store::RawRef::Direct),
2099        expected: match expected_old {
2100            Some(oid) => crate::refs::store::Expected::Oid(oid),
2101            None => crate::refs::store::Expected::Any,
2102        },
2103        reflog: None,
2104        flags: Default::default(),
2105    }
2106}
2107
2108fn push_status_for_ref_store_error(err: &Error) -> PushRefStatus {
2109    match err {
2110        Error::RefStore(crate::refs::store::RefStoreError::ExpectedMismatch { .. }) => {
2111            PushRefStatus::RejectStale
2112        }
2113        _ => PushRefStatus::RemoteRejected,
2114    }
2115}
2116
2117fn ref_store_error_refname(err: &Error) -> Option<String> {
2118    let Error::RefStore(store_err) = err else {
2119        return None;
2120    };
2121    Some(match store_err {
2122        crate::refs::store::RefStoreError::ExpectedMismatch { name, .. }
2123        | crate::refs::store::RefStoreError::DuplicateUpdate { name }
2124        | crate::refs::store::RefStoreError::LockHeld { name }
2125        | crate::refs::store::RefStoreError::InvalidRefName { name }
2126        | crate::refs::store::RefStoreError::NullOid { name }
2127        | crate::refs::store::RefStoreError::NotFound { name } => name.clone(),
2128        crate::refs::store::RefStoreError::NameUnavailable { reason } => match reason {
2129            crate::refs::RefnameUnavailable::SameBatch { refname, .. }
2130            | crate::refs::RefnameUnavailable::AncestorExists {
2131                new_ref: refname, ..
2132            }
2133            | crate::refs::RefnameUnavailable::DescendantExists {
2134                new_ref: refname, ..
2135            } => refname.clone(),
2136        },
2137        crate::refs::store::RefStoreError::SymrefLoop
2138        | crate::refs::store::RefStoreError::Corrupt(_)
2139        | crate::refs::store::RefStoreError::GitDirPathIo { .. }
2140        | crate::refs::store::RefStoreError::InvalidReflog(_)
2141        | crate::refs::store::RefStoreError::Backend(_) => return None,
2142    })
2143}
2144
2145fn apply_ref_store_error_to_push_decision(d: &mut PushDecision, err: &Error, _atomic: bool) {
2146    d.result.status = push_status_for_ref_store_error(err);
2147    d.result.message = Some(err.to_string());
2148    d.apply = false;
2149}
2150
2151fn apply_atomic_batch_ref_store_error(
2152    decisions: &mut [PushDecision],
2153    batch: &[crate::refs::store::RefUpdate],
2154    err: Error,
2155) {
2156    let fail_ref = ref_store_error_refname(&err);
2157    let message = err.to_string();
2158    let batch_names: HashSet<&str> = batch.iter().map(|u| u.name.as_str()).collect();
2159    let in_batch = decisions
2160        .iter_mut()
2161        .filter(|d| d.apply && batch_names.contains(d.result.remote_ref.as_str()));
2162    if let Some(fail_name) = fail_ref {
2163        for d in in_batch {
2164            if d.result.remote_ref == fail_name {
2165                d.result.status = push_status_for_ref_store_error(&err);
2166                d.result.message = Some(message.clone());
2167            } else {
2168                d.result.status = PushRefStatus::AtomicPushFailed;
2169                d.result.message = Some("atomic push failed".to_owned());
2170            }
2171            d.apply = false;
2172        }
2173    } else {
2174        let mut first = true;
2175        for d in in_batch {
2176            if first {
2177                d.result.status = push_status_for_ref_store_error(&err);
2178                d.result.message = Some(message.clone());
2179                first = false;
2180            } else {
2181                d.result.status = PushRefStatus::AtomicPushFailed;
2182                d.result.message = Some("atomic push failed".to_owned());
2183            }
2184            d.apply = false;
2185        }
2186    }
2187}
2188
2189/// What a single accepted push update does once applied.
2190enum PushAction {
2191    /// Copy the closure of `src` to the remote and move the ref to `src`.
2192    Update(ObjectId),
2193    /// Delete the remote ref.
2194    Delete,
2195    /// No mutation (up-to-date or rejected).
2196    None,
2197}
2198
2199/// A decided-but-not-yet-applied push update.
2200struct PushDecision {
2201    result: PushRefResult,
2202    action: PushAction,
2203    /// Whether the second pass should apply `action`.
2204    apply: bool,
2205}
2206
2207/// Decide the status of a single [`PushRefSpec`] without mutating either repo.
2208fn decide_push(
2209    spec: &PushRefSpec,
2210    local_odb: &Odb,
2211    remote_git_dir: &Path,
2212    local_repo: Option<&crate::repo::Repository>,
2213) -> Result<PushDecision> {
2214    let remote_current = crate::refs::resolve_ref(remote_git_dir, &spec.dst).ok();
2215
2216    // Up-to-date trumps every lease: pushing a non-delete to where the remote ref
2217    // already points is a no-op that succeeds even when the force-with-lease
2218    // expectation (a specific `expected_old` value, or `expect_absent`) does not
2219    // hold — "creating/moving a bookmark to the same place it already is is OK".
2220    // Must precede both the absence-lease and compare-and-swap checks below.
2221    if !spec.delete {
2222        if let Some(src) = spec.src {
2223            if remote_current == Some(src) {
2224                return Ok(PushDecision {
2225                    result: PushRefResult {
2226                        local_ref: push_spec_local_ref(spec),
2227                        remote_ref: spec.dst.clone(),
2228                        old_oid: remote_current,
2229                        new_oid: Some(src),
2230                        forced: false,
2231                        deletion: false,
2232                        status: PushRefStatus::UpToDate,
2233                        message: None,
2234                    },
2235                    action: PushAction::None,
2236                    apply: false,
2237                });
2238            }
2239        }
2240    }
2241
2242    // Absence lease (force-with-lease that the ref not exist): once the value is
2243    // actually changing (handled above), a destination that already exists fails
2244    // the lease and is rejected as stale.
2245    if spec.expect_absent && remote_current.is_some() {
2246        return Ok(PushDecision {
2247            result: PushRefResult {
2248                local_ref: push_spec_local_ref(spec),
2249                remote_ref: spec.dst.clone(),
2250                old_oid: remote_current,
2251                new_oid: spec.src,
2252                forced: false,
2253                deletion: spec.delete,
2254                status: PushRefStatus::RejectStale,
2255                message: Some("stale info".to_owned()),
2256            },
2257            action: PushAction::None,
2258            apply: false,
2259        });
2260    }
2261
2262    // Compare-and-swap (force-with-lease): the remote's current value must match
2263    // the caller's expectation, otherwise reject as stale. A `None` expectation
2264    // disables the value check.
2265    if let Some(expected) = spec.expected_old {
2266        if remote_current != Some(expected) {
2267            return Ok(PushDecision {
2268                result: PushRefResult {
2269                    local_ref: None,
2270                    remote_ref: spec.dst.clone(),
2271                    old_oid: remote_current,
2272                    new_oid: spec.src,
2273                    forced: false,
2274                    deletion: spec.delete,
2275                    status: PushRefStatus::RejectStale,
2276                    message: Some("stale info".to_owned()),
2277                },
2278                action: PushAction::None,
2279                apply: false,
2280            });
2281        }
2282    }
2283
2284    if spec.delete {
2285        let (status, action, apply, message) = match remote_current {
2286            Some(_) => (PushRefStatus::Ok, PushAction::Delete, true, None),
2287            None => (
2288                PushRefStatus::RejectRemoteRefMissing,
2289                PushAction::None,
2290                false,
2291                Some("remote ref does not exist".to_owned()),
2292            ),
2293        };
2294        return Ok(PushDecision {
2295            result: PushRefResult {
2296                local_ref: push_spec_local_ref(spec),
2297                remote_ref: spec.dst.clone(),
2298                old_oid: remote_current,
2299                new_oid: None,
2300                forced: false,
2301                deletion: true,
2302                status,
2303                message,
2304            },
2305            action,
2306            apply,
2307        });
2308    }
2309
2310    // Non-delete updates require a source object that exists locally.
2311    let Some(src) = spec.src else {
2312        return Err(Error::Message(format!(
2313            "push to '{}' has no source object and is not a deletion",
2314            spec.dst
2315        )));
2316    };
2317    if !local_odb.contains(&src)? {
2318        return Err(Error::Message(format!(
2319            "source object {src} for '{}' is missing from the local object store",
2320            spec.dst
2321        )));
2322    }
2323
2324    // Unchanged: the remote is already at the source.
2325    if remote_current == Some(src) {
2326        return Ok(PushDecision {
2327            result: PushRefResult {
2328                local_ref: push_spec_local_ref(spec),
2329                remote_ref: spec.dst.clone(),
2330                old_oid: remote_current,
2331                new_oid: Some(src),
2332                forced: false,
2333                deletion: false,
2334                status: PushRefStatus::UpToDate,
2335                message: None,
2336            },
2337            action: PushAction::None,
2338            apply: false,
2339        });
2340    }
2341
2342    // New ref: nothing on the remote yet — always allowed.
2343    let Some(old) = remote_current else {
2344        return Ok(PushDecision {
2345            result: PushRefResult {
2346                local_ref: push_spec_local_ref(spec),
2347                remote_ref: spec.dst.clone(),
2348                old_oid: None,
2349                new_oid: Some(src),
2350                forced: false,
2351                deletion: false,
2352                status: PushRefStatus::Ok,
2353                message: None,
2354            },
2355            action: PushAction::Update(src),
2356            apply: true,
2357        });
2358    };
2359
2360    // Existing ref: fast-forward when the remote's current commit is an ancestor
2361    // of the source. Otherwise it is a non-fast-forward update, allowed only with
2362    // force (reported as forced).
2363    let is_ff = local_repo
2364        .map(|r| crate::merge_base::is_ancestor(r, old, src).unwrap_or(false))
2365        .unwrap_or(false);
2366
2367    if is_ff {
2368        Ok(PushDecision {
2369            result: PushRefResult {
2370                local_ref: push_spec_local_ref(spec),
2371                remote_ref: spec.dst.clone(),
2372                old_oid: Some(old),
2373                new_oid: Some(src),
2374                forced: false,
2375                deletion: false,
2376                status: PushRefStatus::Ok,
2377                message: None,
2378            },
2379            action: PushAction::Update(src),
2380            apply: true,
2381        })
2382    } else if spec.force {
2383        Ok(PushDecision {
2384            result: PushRefResult {
2385                local_ref: push_spec_local_ref(spec),
2386                remote_ref: spec.dst.clone(),
2387                old_oid: Some(old),
2388                new_oid: Some(src),
2389                forced: true,
2390                deletion: false,
2391                status: PushRefStatus::Ok,
2392                message: None,
2393            },
2394            action: PushAction::Update(src),
2395            apply: true,
2396        })
2397    } else {
2398        Ok(PushDecision {
2399            result: PushRefResult {
2400                local_ref: push_spec_local_ref(spec),
2401                remote_ref: spec.dst.clone(),
2402                old_oid: Some(old),
2403                new_oid: Some(src),
2404                forced: false,
2405                deletion: false,
2406                status: PushRefStatus::RejectNonFastForward,
2407                message: Some("non-fast-forward".to_owned()),
2408            },
2409            action: PushAction::None,
2410            apply: false,
2411        })
2412    }
2413}
2414
2415/// A remote ref selected for fetch, with its computed local destination.
2416pub(crate) struct MatchedRef {
2417    pub(crate) remote_ref: String,
2418    /// Destination local tracking ref, or `None` for an empty (no-store) dst.
2419    pub(crate) local_ref: Option<String>,
2420    pub(crate) oid: ObjectId,
2421    pub(crate) force: bool,
2422    pub(crate) is_tag: bool,
2423    /// Local destination is under `refs/tags/` (tag clobber rules apply).
2424    pub(crate) dest_is_tag: bool,
2425    /// Peeled target from ls-refs `peeled:` (annotated tags), when known.
2426    pub(crate) advertised_peel: Option<ObjectId>,
2427    /// How this ref appears in [`FETCH_HEAD`](https://git-scm.com/docs/git-fetch#_discussion).
2428    pub(crate) fetch_head: crate::fetch_request::FetchHeadPolicy,
2429}
2430
2431/// Open an [`Odb`] for a git directory, attaching the git dir so `hash_algo`
2432/// (and MIDX config) resolve correctly.
2433/// `objects/` of `git_dir`, which lives in the common directory for a linked worktree's
2434/// admin dir.
2435fn objects_dir(git_dir: &Path) -> PathBuf {
2436    crate::repo::common_git_dir_for_config(git_dir).join("objects")
2437}
2438
2439pub(crate) fn open_odb(git_dir: &Path) -> Odb {
2440    Odb::new(&objects_dir(git_dir)).with_config_git_dir(git_dir.to_path_buf())
2441}
2442
2443/// Infer the remote repository hash algorithm from fetch capability lines and ref oids.
2444pub(crate) fn remote_hash_algo_from_advertisement(
2445    capability_blocks: &[&[String]],
2446    remote_refs: &[(String, ObjectId)],
2447) -> HashAlgo {
2448    for caps in capability_blocks {
2449        for c in *caps {
2450            if let Some(name) = c.strip_prefix("object-format=") {
2451                if let Some(algo) = HashAlgo::from_name(name) {
2452                    return algo;
2453                }
2454            }
2455        }
2456    }
2457    remote_refs
2458        .iter()
2459        .find_map(|(_, oid)| HashAlgo::from_len(oid.as_bytes().len()))
2460        .unwrap_or(HashAlgo::Sha1)
2461}
2462
2463/// Before the first pack lands in a fresh clone, align `extensions.objectformat` with the remote.
2464///
2465/// Returns whether the local config was rewritten.
2466pub(crate) fn sync_empty_destination_object_format(
2467    local_git_dir: &Path,
2468    remote_algo: HashAlgo,
2469    opts: &FetchOptions,
2470) -> Result<bool> {
2471    if opts.dry_run || !opts.initial_remote_fetch {
2472        return Ok(false);
2473    }
2474    let local_odb = open_odb(local_git_dir);
2475    if local_odb.hash_algo() == remote_algo {
2476        return Ok(false);
2477    }
2478    if local_odb.approximate_object_count()? > 0 {
2479        return Err(Error::Message(format!(
2480            "unsupported object format: destination uses {} but remote uses {}; re-clone into an empty directory with a matching format",
2481            local_odb.hash_algo().name(),
2482            remote_algo.name()
2483        )));
2484    }
2485    write_empty_destination_object_format(local_git_dir, remote_algo)?;
2486    Ok(true)
2487}
2488
2489/// Reopen `local` after [`sync_empty_destination_object_format`] rewrote its object format, so
2490/// its object store uses the new hash algorithm.
2491pub(crate) fn reopen_after_format_change(
2492    local: &crate::repo::Repository,
2493) -> Result<crate::repo::Repository> {
2494    crate::repo::Repository::open(
2495        local.git_dir(),
2496        &RepositoryOptions::with_environment(local.environment().clone()),
2497    )
2498}
2499
2500fn write_empty_destination_object_format(git_dir: &Path, algo: HashAlgo) -> Result<()> {
2501    use crate::config::{ConfigFile, ConfigScope};
2502    let config_path = crate::repo::local_config_write_path(git_dir);
2503    let content = fs::read_to_string(&config_path).unwrap_or_default();
2504    let mut file = ConfigFile::parse(&config_path, &content, ConfigScope::Local)?;
2505    if algo == HashAlgo::Sha1 {
2506        file.unset("extensions.objectformat")?;
2507        file.set("core.repositoryformatversion", "0")?;
2508    } else {
2509        file.set("core.repositoryformatversion", "1")?;
2510        file.set("extensions.objectformat", algo.name())?;
2511    }
2512    file.write()?;
2513    Ok(())
2514}
2515
2516fn config_for_git_dir(git_dir: &Path) -> Option<crate::config::ConfigSet> {
2517    crate::config::ConfigSet::load(
2518        &crate::environment::Environment::empty(),
2519        Some(git_dir),
2520        true,
2521    )
2522    .ok()
2523}
2524
2525/// Write [`FETCH_HEAD`](https://git-scm.com/docs/git-fetch#_discussion) for anonymous URL/path fetches.
2526pub(crate) fn finish_anonymous_url_fetch(
2527    git_dir: &Path,
2528    opts: &FetchOptions,
2529    explicit_refspec_fetch: bool,
2530    default_branch: Option<&str>,
2531    remote_refs: &[(String, ObjectId)],
2532    matched: &[MatchedRef],
2533) -> Result<()> {
2534    if opts.dry_run {
2535        return Ok(());
2536    }
2537    let write_fetch_head = opts.remote_name.is_none() || explicit_refspec_fetch;
2538    if !write_fetch_head {
2539        return Ok(());
2540    }
2541    let source = opts.fetch_source.clone().or_else(|| {
2542        let name = opts.remote_name.as_deref()?;
2543        let config = config_for_git_dir(git_dir)?;
2544        config
2545            .get(&format!("remote.{name}.url"))
2546            .filter(|url| !url.trim().is_empty())
2547    });
2548    let Some(source) = source.as_deref() else {
2549        return Ok(());
2550    };
2551    let lines = fetch_head_lines_for_ad_hoc_fetch(
2552        matched,
2553        remote_refs,
2554        &opts.refspecs,
2555        default_branch,
2556        source,
2557    );
2558    if lines.is_empty() {
2559        return Ok(());
2560    }
2561    crate::fetch_head::write_fetch_head(git_dir, &lines)
2562}
2563
2564fn fetch_head_lines_for_ad_hoc_fetch(
2565    matched: &[MatchedRef],
2566    remote_refs: &[(String, ObjectId)],
2567    refspecs: &[String],
2568    default_branch: Option<&str>,
2569    source: &str,
2570) -> Vec<crate::fetch_head::FetchHeadLine> {
2571    use crate::fetch_head::FetchHeadLine;
2572    use crate::remote::Remote;
2573
2574    let anonymous_only = refspecs.len() == 1
2575        && refspecs[0].trim_start_matches('+') == Remote::ANONYMOUS_FETCH_REFSPEC;
2576
2577    if anonymous_only {
2578        if let Some(branch) = default_branch {
2579            let head_ref = format!("refs/heads/{branch}");
2580            if let Some(m) = matched
2581                .iter()
2582                .find(|m| m.remote_ref == head_ref && !m.is_tag)
2583            {
2584                return vec![FetchHeadLine {
2585                    oid: m.oid,
2586                    for_merge: true,
2587                    description: source.to_owned(),
2588                }];
2589            }
2590        }
2591        if let Some((_, oid)) = remote_refs.iter().find(|(name, _)| name == "HEAD") {
2592            return vec![FetchHeadLine {
2593                oid: *oid,
2594                for_merge: true,
2595                description: source.to_owned(),
2596            }];
2597        }
2598        return Vec::new();
2599    }
2600
2601    matched
2602        .iter()
2603        .filter(|m| m.fetch_head == crate::fetch_request::FetchHeadPolicy::ForMerge)
2604        .map(|m| FetchHeadLine {
2605            oid: m.oid,
2606            for_merge: true,
2607            description: crate::fetch_request::fetch_head_description(
2608                &m.remote_ref,
2609                m.is_tag,
2610                source,
2611            ),
2612        })
2613        .collect()
2614}
2615
2616/// Tip oid of `HEAD` from a ref advertisement (before `HEAD` is stripped).
2617#[must_use]
2618pub(crate) fn remote_head_oid_from_advertised(
2619    advertised: &[(String, ObjectId)],
2620) -> Option<ObjectId> {
2621    advertised
2622        .iter()
2623        .find(|(name, _)| name == "HEAD")
2624        .map(|(_, oid)| *oid)
2625}
2626
2627/// Match a ref name against the positive refspecs, returning the destination
2628/// local ref name (`Some(name)`), `None`+stored=false collapsed: returns
2629/// `Some(Some(dst))` to store, `Some(None)` to fetch-without-store, or `None`
2630/// when no positive refspec matches.
2631pub(crate) fn match_positive(refname: &str, positive: &[RefspecItem]) -> Option<Option<String>> {
2632    for item in positive {
2633        let Some(src) = item.src.as_deref() else {
2634            continue;
2635        };
2636        if let Some(dst) = apply_refspec(src, item.dst.as_deref(), refname) {
2637            // Empty dst means "fetch but do not store".
2638            if dst.is_empty() {
2639                return Some(None);
2640            }
2641            return Some(Some(dst));
2642        }
2643    }
2644    None
2645}
2646
2647/// Whether any positive refspec matching `refname` requested force (`+`).
2648pub(crate) fn refspecs_force(refname: &str, positive: &[RefspecItem]) -> bool {
2649    positive.iter().any(|item| {
2650        item.force
2651            && item
2652                .src
2653                .as_deref()
2654                .is_some_and(|src| apply_refspec(src, item.dst.as_deref(), refname).is_some())
2655    })
2656}
2657
2658/// Whether `refname` is excluded by any negative refspec.
2659pub(crate) fn ref_excluded(refname: &str, negatives: &[RefspecItem]) -> bool {
2660    negatives.iter().any(|item| {
2661        item.src
2662            .as_deref()
2663            .is_some_and(|src| glob_matches(src, refname))
2664    })
2665}
2666
2667/// Apply a `<src>[:<dst>]` refspec to `refname`, returning the destination ref.
2668///
2669/// Supports a single `*` wildcard (Git's fetch refspec form). When `dst` is
2670/// `None` the destination equals the matched source (rare for tracking fetches);
2671/// when `dst` is `Some("")` the empty string is returned (fetch-without-store).
2672pub(crate) fn apply_refspec(src: &str, dst: Option<&str>, refname: &str) -> Option<String> {
2673    match src.find('*') {
2674        Some(star) => {
2675            let prefix = &src[..star];
2676            let suffix = &src[star + 1..];
2677            if !refname.starts_with(prefix)
2678                || !refname.ends_with(suffix)
2679                || refname.len() < prefix.len() + suffix.len()
2680            {
2681                return None;
2682            }
2683            let middle = &refname[prefix.len()..refname.len() - suffix.len()];
2684            match dst {
2685                None => Some(refname.to_owned()),
2686                Some("") => Some(String::new()),
2687                Some(d) => Some(d.replacen('*', middle, 1)),
2688            }
2689        }
2690        None => {
2691            if src != refname {
2692                return None;
2693            }
2694            match dst {
2695                None => Some(refname.to_owned()),
2696                Some("") => Some(String::new()),
2697                Some(d) => Some(d.to_owned()),
2698            }
2699        }
2700    }
2701}
2702
2703/// Whether `pattern` (a refspec src side, possibly with one `*`) matches `refname`.
2704fn glob_matches(pattern: &str, refname: &str) -> bool {
2705    match pattern.find('*') {
2706        Some(star) => {
2707            let prefix = &pattern[..star];
2708            let suffix = &pattern[star + 1..];
2709            refname.starts_with(prefix)
2710                && refname.ends_with(suffix)
2711                && refname.len() >= prefix.len() + suffix.len()
2712        }
2713        None => pattern == refname,
2714    }
2715}
2716
2717/// Add tags to the matched set according to [`TagMode`].
2718///
2719/// Under `Following`, tags named in `already_local` (see
2720/// [`crate::fetch::tags_already_local`]) are skipped, matching Git's auto-follow,
2721/// which never reconsiders a tag the local repository already has.
2722#[allow(clippy::too_many_arguments)]
2723pub(crate) fn apply_tag_mode(
2724    mode: TagMode,
2725    remote_refs: &[(String, ObjectId)],
2726    remote_odb: &Odb,
2727    shallow_boundaries: &HashSet<ObjectId>,
2728    negatives: &[RefspecItem],
2729    already_local: &HashSet<String>,
2730    matched: &mut Vec<MatchedRef>,
2731    matched_oids: &mut HashSet<ObjectId>,
2732    seen_remote_ref: &mut HashSet<String>,
2733) -> Result<HashSet<ObjectId>> {
2734    let mut following_only = HashSet::new();
2735    if mode == TagMode::None {
2736        return Ok(following_only);
2737    }
2738
2739    // Tags this call may add, with the object each one peels to (`None` when
2740    // the tag cannot be peeled; only the tag oid itself can then match).
2741    let candidates: Vec<(&String, ObjectId, Option<ObjectId>)> = remote_refs
2742        .iter()
2743        .filter(|(name, _)| {
2744            name.starts_with("refs/tags/")
2745                && !seen_remote_ref.contains(name)
2746                && !ref_excluded(name, negatives)
2747                && !(mode == TagMode::Following && already_local.contains(name))
2748        })
2749        .map(|(name, oid)| {
2750            let peeled = match mode {
2751                TagMode::Following => remote_odb.peel_tags(*oid).ok().map(|(p, _)| p),
2752                _ => None,
2753            };
2754            (name, *oid, peeled)
2755        })
2756        .collect();
2757
2758    // For Following, decide reachability on the source ODB using the effective
2759    // shallow grafts for this fetch. Post-pack pruning via
2760    // [`crate::fetch::retain_following_tags`] still drops tags whose objects did
2761    // not arrive. Using the local ODB here would miss every tip on first fetch.
2762    let following_closure: HashSet<ObjectId> = if mode == TagMode::Following {
2763        let roots: Vec<ObjectId> = matched
2764            .iter()
2765            .filter(|m| !m.is_tag)
2766            .map(|m| m.oid)
2767            .collect();
2768        let targets: HashSet<ObjectId> = candidates
2769            .iter()
2770            .map(|(_, oid, peeled)| peeled.unwrap_or(*oid))
2771            .collect();
2772        crate::fetch::reachable_commits(remote_odb, &roots, &targets, shallow_boundaries)?
2773    } else {
2774        HashSet::new()
2775    };
2776
2777    for (name, oid, peeled) in candidates {
2778        let keep = match mode {
2779            TagMode::All => true,
2780            // Keep when the tag (or what it peels to) is in the fetched closure.
2781            TagMode::Following => {
2782                following_closure.contains(&oid)
2783                    || peeled.is_some_and(|p| following_closure.contains(&p))
2784            }
2785            TagMode::None => false,
2786        };
2787        if keep && seen_remote_ref.insert(name.clone()) {
2788            if matched_oids.insert(oid) && mode == TagMode::Following {
2789                following_only.insert(oid);
2790            }
2791            matched.push(MatchedRef {
2792                remote_ref: name.clone(),
2793                local_ref: Some(name.clone()),
2794                oid,
2795                force: false,
2796                is_tag: true,
2797                dest_is_tag: true,
2798                advertised_peel: peeled,
2799                fetch_head: crate::fetch_request::FetchHeadPolicy::ForMerge,
2800            });
2801        }
2802    }
2803    Ok(following_only)
2804}
2805
2806/// Commit tip to use when computing shallow boundaries for one matched ref.
2807fn shallow_want_commit_tip(remote_odb: &Odb, matched: &MatchedRef) -> Result<ObjectId> {
2808    if !matched.is_tag {
2809        return Ok(matched.oid);
2810    }
2811    if let Some(peel) = matched.advertised_peel.filter(|p| !p.is_zero()) {
2812        return Ok(peel);
2813    }
2814    if remote_odb.contains(&matched.oid)? {
2815        let (peeled, _) = remote_odb.peel_tags(matched.oid)?;
2816        return Ok(peeled);
2817    }
2818    Ok(matched.oid)
2819}
2820
2821/// Whether a tag ref's peeled target (or the oid itself for lightweight tags)
2822/// exists in the local or remote object database.
2823fn ref_target_exists(local_odb: &Odb, remote_odb: &Odb, tag_oid: ObjectId) -> Result<bool> {
2824    let peel_odb = if local_odb.contains(&tag_oid)? {
2825        local_odb
2826    } else {
2827        remote_odb
2828    };
2829    match peel_odb.peel_tags(tag_oid) {
2830        Ok((peeled, _)) => local_odb.contains(&peeled),
2831        Err(_) => Ok(false),
2832    }
2833}
2834
2835/// Classify a single ref update into an [`UpdateMode`].
2836///
2837/// When `classify_odb` is set (network fetch `--dry-run` after quarantined pack
2838/// ingest), ancestry uses that store with its configured alternates instead of
2839/// opening a [`crate::repo::Repository`].
2840pub(crate) fn classify_update(
2841    old: Option<&ObjectId>,
2842    new: &ObjectId,
2843    force: bool,
2844    is_tag: bool,
2845    repo: Option<&crate::repo::Repository>,
2846    classify_odb: Option<&crate::odb::Odb>,
2847) -> UpdateMode {
2848    let Some(old) = old else {
2849        return UpdateMode::New;
2850    };
2851    if old == new {
2852        return UpdateMode::UpToDate;
2853    }
2854    // Fast-forward when old is an ancestor of new (commit history only).
2855    let ff = if let Some(r) = repo {
2856        crate::merge_base::is_ancestor(r, *old, *new).unwrap_or(false)
2857    } else if let Some(odb) = classify_odb {
2858        crate::merge_base::is_ancestor_odb_walk(odb, *old, *new).unwrap_or(false)
2859    } else {
2860        false
2861    };
2862    if ff && !is_tag {
2863        return UpdateMode::FastForward;
2864    }
2865    if force {
2866        return UpdateMode::Forced;
2867    }
2868    if is_tag {
2869        return UpdateMode::TagUpdateRejected;
2870    }
2871    UpdateMode::NonFastForwardRejected
2872}
2873
2874/// Default-branch symref for a wildcard refspec destination (`<prefix>HEAD`).
2875///
2876/// Remote names may contain `/` (e.g. `team/origin` → `refs/remotes/team/origin/HEAD`).
2877fn is_default_head_under_tracking_prefix(refname: &str, tracking_prefix: &str) -> bool {
2878    refname == format!("{tracking_prefix}HEAD")
2879}
2880
2881/// Local tracking refs that would be removed by `git fetch --prune` given the current remote.
2882///
2883/// Returns full ref names and their current object ids. The default-branch symref
2884/// `refs/remotes/<remote>/HEAD` is never included.
2885///
2886/// # Errors
2887///
2888/// Propagates ref listing failures.
2889pub fn tracking_refs_gone_on_remote(
2890    local_git_dir: &Path,
2891    positive: &[RefspecItem],
2892    remote_refs: &[(String, ObjectId)],
2893) -> Result<Vec<(String, ObjectId)>> {
2894    let mut live: HashSet<String> = HashSet::new();
2895    for (name, _) in remote_refs {
2896        if let Some(Some(dst)) = match_positive(name, positive) {
2897            live.insert(dst);
2898        }
2899    }
2900
2901    let mut pruned: HashMap<String, ObjectId> = HashMap::new();
2902    for item in positive {
2903        let Some(dst) = item.dst.as_deref() else {
2904            continue;
2905        };
2906        if let Some(star) = dst.find('*') {
2907            let prefix = &dst[..star];
2908            for (name, oid) in crate::refs::list_refs(local_git_dir, prefix)? {
2909                if !name.starts_with(prefix) {
2910                    continue;
2911                }
2912                if is_default_head_under_tracking_prefix(&name, prefix) {
2913                    continue;
2914                }
2915                if !live.contains(&name) {
2916                    pruned.entry(name).or_insert(oid);
2917                }
2918            }
2919        } else if !live.contains(dst) {
2920            let exact_prefix = dst.strip_suffix("HEAD").filter(|p| p.ends_with('/'));
2921            if exact_prefix.is_some_and(|p| is_default_head_under_tracking_prefix(dst, p)) {
2922                continue;
2923            }
2924            if let Ok(oid) = crate::refs::resolve_ref(local_git_dir, dst) {
2925                pruned.entry(dst.to_owned()).or_insert(oid);
2926            }
2927        }
2928    }
2929    Ok(pruned.into_iter().collect())
2930}
2931
2932/// Optional prune hint when a fetch ref write is rejected.
2933pub(crate) struct FetchRefRejectHint<'a> {
2934    /// Whether this fetch runs with `--prune` (no stale-ref hints when true).
2935    pub prune: bool,
2936    /// Positive refspecs for the fetch (stale tracking detection).
2937    pub positive: &'a [RefspecItem],
2938    /// Advertised remote refs at fetch time.
2939    pub remote_refs: &'a [(String, ObjectId)],
2940    /// Remote nickname (`origin`, …) for hint text.
2941    pub remote_name: Option<&'a str>,
2942}
2943
2944fn fetch_ref_store_error_rejectable(err: &Error) -> bool {
2945    matches!(
2946        err,
2947        Error::RefStore(crate::refs::store::RefStoreError::NameUnavailable { .. })
2948            | Error::RefStore(crate::refs::store::RefStoreError::LockHeld { .. })
2949    )
2950}
2951
2952fn blocking_ref_from_unavailable(reason: &crate::refs::RefnameUnavailable) -> Option<&str> {
2953    match reason {
2954        crate::refs::RefnameUnavailable::AncestorExists { blocking, .. }
2955        | crate::refs::RefnameUnavailable::DescendantExists { blocking, .. } => Some(blocking),
2956        crate::refs::RefnameUnavailable::SameBatch { .. } => None,
2957    }
2958}
2959
2960fn append_fetch_prune_hint(
2961    note: &mut String,
2962    stale_tracking: &HashSet<String>,
2963    blocking: &str,
2964    remote_name: Option<&str>,
2965) {
2966    if stale_tracking.contains(blocking) {
2967        let remote = remote_name.unwrap_or("origin");
2968        note.push_str(&format!(
2969            " — stale remote-tracking ref; try `grit fetch --prune` or `grit remote prune {remote}`"
2970        ));
2971    }
2972}
2973
2974fn mark_fetch_ref_update_rejected(
2975    updates: &mut [RefUpdate],
2976    local_ref: &str,
2977    err: &Error,
2978    stale_tracking: &HashSet<String>,
2979    hint: &FetchRefRejectHint<'_>,
2980) {
2981    let Some(update) = updates
2982        .iter_mut()
2983        .find(|u| u.local_ref.as_deref() == Some(local_ref))
2984    else {
2985        return;
2986    };
2987    update.mode = UpdateMode::RefLockRejected;
2988    let mut note = match err {
2989        Error::RefStore(crate::refs::store::RefStoreError::NameUnavailable { reason }) => {
2990            let mut text = reason.lock_message_suffix();
2991            if let Some(blocking) = blocking_ref_from_unavailable(reason) {
2992                append_fetch_prune_hint(&mut text, stale_tracking, blocking, hint.remote_name);
2993            }
2994            text
2995        }
2996        Error::RefStore(crate::refs::store::RefStoreError::LockHeld { name }) => {
2997            format!("ref '{name}' is locked")
2998        }
2999        _ => err.to_string(),
3000    };
3001    if note.is_empty() {
3002        note = "unable to update local ref".to_owned();
3003    }
3004    update.note = Some(note);
3005}
3006
3007/// Commit tracking-ref writes from a fetch, applying every ref that can be updated.
3008///
3009/// Uses one batch transaction when the full batch validates; otherwise falls back to
3010/// per-ref commits and records lock or namespace failures on the matching
3011/// [`RefUpdate`] rows instead of aborting the whole fetch.
3012///
3013/// # Errors
3014///
3015/// Propagates non-rejectable ref-store and I/O errors from the fallback path.
3016pub(crate) fn apply_fetch_ref_updates(
3017    local_git_dir: &Path,
3018    store_batch: &[crate::refs::store::RefUpdate],
3019    updates: &mut [RefUpdate],
3020    hint: FetchRefRejectHint<'_>,
3021) -> Result<()> {
3022    if store_batch.is_empty() {
3023        return Ok(());
3024    }
3025    if crate::refs::commit_ref_store_batch(local_git_dir, store_batch).is_ok() {
3026        return Ok(());
3027    }
3028
3029    let stale_tracking: HashSet<String> = if hint.prune {
3030        HashSet::new()
3031    } else {
3032        tracking_refs_gone_on_remote(local_git_dir, hint.positive, hint.remote_refs)?
3033            .into_iter()
3034            .map(|(name, _)| name)
3035            .collect()
3036    };
3037
3038    for store_update in store_batch {
3039        match crate::refs::commit_store_update(local_git_dir, store_update.clone()) {
3040            Ok(()) => {}
3041            Err(err) if fetch_ref_store_error_rejectable(&err) => {
3042                mark_fetch_ref_update_rejected(
3043                    updates,
3044                    &store_update.name,
3045                    &err,
3046                    &stale_tracking,
3047                    &hint,
3048                );
3049            }
3050            Err(err) => return Err(err),
3051        }
3052    }
3053    Ok(())
3054}
3055
3056/// Delete local tracking refs whose remote counterpart no longer exists.
3057///
3058/// A local tracking ref is a prune candidate when it lives under the destination
3059/// namespace of some positive wildcard refspec and no current remote ref maps to
3060/// it. Matches `git fetch --prune` for the common `refs/remotes/<remote>/*` case.
3061pub(crate) fn prune_tracking_refs(
3062    local_git_dir: &Path,
3063    positive: &[RefspecItem],
3064    remote_refs: &[(String, ObjectId)],
3065    dry_run: bool,
3066    updates: &mut Vec<RefUpdate>,
3067    mut store_batch: Option<&mut Vec<crate::refs::store::RefUpdate>>,
3068) -> Result<()> {
3069    let pruned: Vec<(String, ObjectId)> =
3070        tracking_refs_gone_on_remote(local_git_dir, positive, remote_refs)?;
3071
3072    let prune_delete_flags = crate::refs::store::RefUpdateFlags {
3073        no_deref: true,
3074        ..Default::default()
3075    };
3076    for (name, oid) in pruned.into_iter() {
3077        if !dry_run {
3078            if let Some(batch) = store_batch.as_mut() {
3079                batch.push(crate::refs::store::RefUpdate {
3080                    name: name.clone(),
3081                    new_value: None,
3082                    expected: crate::refs::store::Expected::Any,
3083                    reflog: None,
3084                    flags: prune_delete_flags,
3085                });
3086            } else {
3087                crate::refs::delete_ref(local_git_dir, &name)?;
3088            }
3089        }
3090        updates.push(RefUpdate {
3091            remote_ref: String::new(),
3092            local_ref: Some(name),
3093            old_oid: Some(oid),
3094            new_oid: None,
3095            mode: UpdateMode::DeletedMissing,
3096            note: Some("pruned (gone on remote)".to_owned()),
3097        });
3098    }
3099    Ok(())
3100}
3101
3102#[cfg(test)]
3103mod tests {
3104    use super::*;
3105    use crate::progress::ProgressPhase;
3106    use crate::ref_storage::RefStorageFormat;
3107    use crate::repo::init_repository;
3108    use std::process::Command;
3109
3110    #[derive(Debug, Default)]
3111    struct PhaseRecorder {
3112        events: Vec<&'static str>,
3113        started: Vec<(ProgressPhase, Option<u64>)>,
3114        finished: Vec<ProgressPhase>,
3115        counts: Vec<u64>,
3116        active: Option<ProgressPhase>,
3117        current: u64,
3118    }
3119
3120    impl ProgressSink for PhaseRecorder {
3121        fn start(&mut self, phase: ProgressPhase, total: Option<u64>) {
3122            self.events.push("start");
3123            self.started.push((phase, total));
3124            self.active = Some(phase);
3125            self.current = 0;
3126        }
3127        fn inc(&mut self, units: u64) {
3128            self.events.push("inc");
3129            self.current += units;
3130            self.counts.push(self.current);
3131        }
3132        fn set(&mut self, current: u64) {
3133            self.events.push("set");
3134            self.current = current;
3135            self.counts.push(self.current);
3136        }
3137        fn finish(&mut self) {
3138            self.events.push("finish");
3139            if let Some(phase) = self.active.take() {
3140                self.finished.push(phase);
3141            }
3142        }
3143    }
3144
3145    impl Progress for PhaseRecorder {}
3146
3147    fn git_ok(dir: &Path, args: &[&str]) -> bool {
3148        Command::new("git") // hygiene: git fixture comparison in cfg(test) module
3149            .env("GIT_AUTHOR_NAME", "Test")
3150            .env("GIT_AUTHOR_EMAIL", "test@example.com")
3151            .env("GIT_COMMITTER_NAME", "Test")
3152            .env("GIT_COMMITTER_EMAIL", "test@example.com")
3153            .current_dir(dir)
3154            .args(args)
3155            .env("GIT_CONFIG_GLOBAL", "/dev/null")
3156            .env("GIT_CONFIG_SYSTEM", "/dev/null")
3157            .status()
3158            .map(|s| s.success())
3159            .unwrap_or(false)
3160    }
3161
3162    fn assert_monotonic(counts: &[u64]) {
3163        for pair in counts.windows(2) {
3164            assert!(pair[1] >= pair[0], "progress counts must be monotonic");
3165        }
3166    }
3167
3168    #[test]
3169    fn local_fetch_classifies_through_repository_ref_store() {
3170        use crate::refs::store::{MemoryRefStore, RawRef};
3171        use std::sync::Arc;
3172
3173        let tmp = tempfile::tempdir().unwrap();
3174        assert!(git_ok(tmp.path(), &["init", "-q", "-b", "main"]));
3175        assert!(git_ok(
3176            tmp.path(),
3177            &["commit", "--allow-empty", "-qm", "base"]
3178        ));
3179        let repo =
3180            crate::repo::Repository::open(&tmp.path().join(".git"), &RepositoryOptions::empty())
3181                .unwrap();
3182        let tip = repo.resolve_ref_oid("HEAD").unwrap();
3183        let store = Arc::new(MemoryRefStore::new());
3184        store
3185            .set_ref("refs/remotes/origin/main", RawRef::Direct(tip))
3186            .unwrap();
3187        let repo = repo.with_ref_store(store);
3188        let opts = FetchOptions {
3189            refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".into()],
3190            tags: TagMode::None,
3191            dry_run: true,
3192            ..Default::default()
3193        };
3194        let outcome = fetch_local_into(
3195            &repo,
3196            &tmp.path().join(".git"),
3197            &opts,
3198            &mut PhaseRecorder::default(),
3199            &crate::progress::NeverCancel,
3200        )
3201        .unwrap();
3202        assert_eq!(outcome.transfer.objects, 0);
3203        assert_eq!(outcome.updates.len(), 1);
3204        assert_eq!(outcome.updates[0].old_oid, Some(tip));
3205        assert_eq!(outcome.updates[0].mode, UpdateMode::UpToDate);
3206    }
3207
3208    #[test]
3209    fn fetch_local_reports_copy_progress() {
3210        let remote = tempfile::tempdir().expect("remote");
3211        assert!(git_ok(
3212            remote.path(),
3213            &["init", "-q", "-b", "main", "--bare"]
3214        ));
3215        let src = tempfile::tempdir().expect("src");
3216        assert!(git_ok(src.path(), &["init", "-q", "-b", "main"]));
3217        std::fs::write(src.path().join("f.txt"), "payload\n").unwrap();
3218        assert!(git_ok(src.path(), &["add", "f.txt"]));
3219        assert!(git_ok(src.path(), &["commit", "-qm", "c1"]));
3220        assert!(git_ok(
3221            src.path(),
3222            &["push", "-q", remote.path().to_str().unwrap(), "main:main",],
3223        ));
3224
3225        let local = tempfile::tempdir().expect("local");
3226        init_repository(local.path(), false, "main", None, RefStorageFormat::Files).expect("init");
3227
3228        let opts = FetchOptions {
3229            refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
3230            initial_remote_fetch: true,
3231            tags: TagMode::None,
3232            ..Default::default()
3233        };
3234        let mut rec = PhaseRecorder::default();
3235        fetch_local_with_progress(
3236            &local.path().join(".git"),
3237            remote.path(),
3238            &opts,
3239            &mut rec,
3240            &crate::progress::NeverCancel,
3241        )
3242        .expect("fetch_local");
3243
3244        assert!(
3245            rec.started
3246                .iter()
3247                .any(|(p, _)| *p == ProgressPhase::CopyingObjects),
3248            "expected CopyingObjects phase"
3249        );
3250        assert!(
3251            rec.finished.contains(&ProgressPhase::CopyingObjects),
3252            "expected CopyingObjects finish"
3253        );
3254        assert!(!rec.counts.is_empty(), "expected copy progress counts");
3255        assert_monotonic(&rec.counts);
3256        let first_progress = rec.events.iter().find(|e| **e != "start").copied();
3257        assert_eq!(
3258            rec.events.first().copied(),
3259            Some("start"),
3260            "CopyingObjects must start before link/copy work reports progress"
3261        );
3262        assert!(
3263            first_progress.is_some(),
3264            "expected set/inc after start during copy"
3265        );
3266    }
3267
3268    #[test]
3269    fn transfer_stats_add_up_and_keep_kinds_only_when_known() {
3270        use crate::index_pack::ObjectKindCounts;
3271        let kinds = |commits, blobs| ObjectKindCounts {
3272            commits,
3273            trees: 0,
3274            blobs,
3275            tags: 0,
3276        };
3277        let mut total = TransferStats::default();
3278        total += TransferStats {
3279            objects: 3,
3280            bytes: 100,
3281            kinds: Some(kinds(1, 2)),
3282        };
3283        // An up-to-date remote adds nothing and does not erase the breakdown.
3284        total += TransferStats::default();
3285        total += TransferStats {
3286            objects: 2,
3287            bytes: 50,
3288            kinds: Some(kinds(1, 1)),
3289        };
3290        assert_eq!(
3291            total,
3292            TransferStats {
3293                objects: 5,
3294                bytes: 150,
3295                kinds: Some(kinds(2, 3)),
3296            }
3297        );
3298
3299        // A transfer without a breakdown makes the combined breakdown unknown.
3300        total += TransferStats {
3301            objects: 4,
3302            bytes: 10,
3303            kinds: None,
3304        };
3305        assert_eq!(total.objects, 9);
3306        assert_eq!(total.bytes, 160);
3307        assert_eq!(total.kinds, None);
3308    }
3309
3310    #[test]
3311    fn transfer_stats_for_local_operation_counts_new_pack_index() {
3312        let tmp = tempfile::tempdir().expect("tempdir");
3313        let repo = tmp.path().join("repo");
3314        std::fs::create_dir_all(&repo).unwrap();
3315        assert!(git_ok(&repo, &["init", "-q", "-b", "main", "."]));
3316        std::fs::write(repo.join("blob.txt"), b"payload\n").unwrap();
3317        assert!(git_ok(&repo, &["add", "blob.txt"]));
3318        assert!(git_ok(&repo, &["commit", "-qm", "c"]));
3319        assert!(git_ok(&repo, &["repack", "-a", "-d", "-f"]));
3320
3321        let git_dir = repo.join(".git");
3322        let pack_dir = git_dir.join("objects/pack");
3323        let odb = Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.clone());
3324        let stats = transfer_stats_for_local_operation(&odb, &HashSet::new(), &HashSet::new())
3325            .expect("stats");
3326        assert!(stats.bytes > 0);
3327        assert!(stats.objects > 0);
3328
3329        let pack_path = std::fs::read_dir(&pack_dir)
3330            .expect("read pack dir")
3331            .filter_map(|e| e.ok())
3332            .map(|e| e.path())
3333            .find(|p| p.extension().is_some_and(|e| e == "pack"))
3334            .expect("pack file");
3335        let idx = pack_path.with_extension("idx");
3336        let index = crate::pack::read_pack_index_no_verify(&idx).expect("idx");
3337        assert_eq!(
3338            stats.objects,
3339            u64::try_from(index.len()).expect("count"),
3340            "object count must match the on-disk index entry count"
3341        );
3342    }
3343
3344    #[test]
3345    fn update_mode_modified_repo_excludes_rejected_tag_updates() {
3346        assert!(UpdateMode::New.modified_repo());
3347        assert!(UpdateMode::DeletedMissing.modified_repo());
3348        assert!(!UpdateMode::TagUpdateRejected.modified_repo());
3349        assert!(!UpdateMode::UpToDate.modified_repo());
3350    }
3351
3352    #[test]
3353    fn update_mode_report_classification_covers_every_mode() {
3354        use UpdateMode::*;
3355        for (mode, reported, rejected) in [
3356            (New, true, false),
3357            (FastForward, true, false),
3358            (Forced, true, false),
3359            (DeletedMissing, true, false),
3360            (NonFastForwardRejected, true, true),
3361            (TagUpdateRejected, true, true),
3362            (SourceObjectNotFound, true, true),
3363            (UpToDate, false, false),
3364            (NoChangeNeeded, false, false),
3365            (Unborn, false, false),
3366        ] {
3367            assert_eq!(mode.is_reported(), reported, "{mode:?}");
3368            assert_eq!(mode.is_rejected(), rejected, "{mode:?}");
3369            assert!(!(mode.modified_repo() && mode.is_rejected()), "{mode:?}");
3370        }
3371    }
3372
3373    #[test]
3374    fn fetch_outcome_summarizes_reported_changed_and_rejected_refs() {
3375        let update = |name: &str, mode| RefUpdate {
3376            remote_ref: format!("refs/heads/{name}"),
3377            local_ref: Some(format!("refs/remotes/origin/{name}")),
3378            old_oid: None,
3379            new_oid: None,
3380            mode,
3381            note: None,
3382        };
3383        let outcome = FetchOutcome {
3384            updates: vec![
3385                update("same", UpdateMode::UpToDate),
3386                update("new", UpdateMode::New),
3387                update("ff", UpdateMode::FastForward),
3388                update("noop", UpdateMode::NoChangeNeeded),
3389            ],
3390            ..Default::default()
3391        };
3392        let reported: Vec<&str> = outcome
3393            .reported_updates()
3394            .map(|u| u.remote_ref.as_str())
3395            .collect();
3396        assert_eq!(reported, ["refs/heads/new", "refs/heads/ff"]);
3397        assert_eq!(outcome.changed_ref_count(), 2);
3398        assert!(!outcome.has_rejections());
3399
3400        let rejected = FetchOutcome {
3401            updates: vec![update("diverged", UpdateMode::NonFastForwardRejected)],
3402            ..Default::default()
3403        };
3404        assert_eq!(rejected.changed_ref_count(), 0);
3405        assert!(rejected.has_rejections());
3406        assert_eq!(rejected.reported_updates().count(), 1);
3407        assert!(FetchOutcome::default().reported_updates().next().is_none());
3408    }
3409}