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 std::collections::{HashMap, HashSet, VecDeque};
19use std::io::Write;
20use std::path::Path;
21
22use flate2::write::ZlibEncoder;
23use flate2::Compression;
24use sha1::{Digest as _, Sha1};
25use sha2::Sha256;
26
27use crate::delta_encode::{encode_lcp_delta, encode_prefix_extension_delta};
28use crate::error::{Error, Result};
29use crate::objects::{parse_commit, parse_tag, parse_tree, HashAlgo, Object, ObjectId, ObjectKind};
30use crate::odb::Odb;
31use crate::push_report::{PushRefResult, PushRefStatus};
32use crate::refspec::{parse_fetch_refspec, RefspecItem};
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}
61
62/// The resolved outcome of one reference during a fetch.
63#[derive(Clone, Debug)]
64pub struct RefUpdate {
65    /// The remote-side ref name (e.g. `refs/heads/main`).
66    pub remote_ref: String,
67    /// The local-side ref name written, if any (e.g. `refs/remotes/origin/main`).
68    pub local_ref: Option<String>,
69    /// Previous value of the local ref (`None` when newly created).
70    pub old_oid: Option<ObjectId>,
71    /// New value written to the local ref (`None` for deletions / unborn).
72    pub new_oid: Option<ObjectId>,
73    /// How the update resolved.
74    pub mode: UpdateMode,
75    /// Optional human-readable note (reason text), for embedder display.
76    pub note: Option<String>,
77}
78
79/// Which tags to fetch alongside the requested refs.
80#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
81pub enum TagMode {
82    /// Do not fetch any tags automatically.
83    None,
84    /// Fetch tags that point at objects being fetched (Git's default).
85    #[default]
86    Following,
87    /// Fetch all tags from the remote.
88    All,
89}
90
91/// Options controlling a fetch.
92#[derive(Clone, Debug)]
93pub struct FetchOptions {
94    /// Positive refspecs selecting what to fetch.
95    pub refspecs: Vec<String>,
96    /// Negative refspecs excluding refs from the positive set.
97    pub negative_refspecs: Vec<String>,
98    /// Tag-following policy.
99    pub tags: TagMode,
100    /// Whether to prune local tracking refs that vanished on the remote.
101    pub prune: bool,
102    /// Compute and report updates without writing any refs or objects.
103    pub dry_run: bool,
104    /// Truncate history to the given number of commits per tip
105    /// (`git fetch --depth N`). Drives the wire `deepen N` / v2 `deepen` arg and,
106    /// for a previously shallow repo, deepens the existing boundary. `None`
107    /// requests full history.
108    pub depth: Option<u32>,
109    /// Deepen history to include commits no older than this cutoff
110    /// (`git fetch --shallow-since <date>`). The value is sent verbatim as the
111    /// wire `deepen-since <value>`; callers should pass the Unix timestamp Git's
112    /// `upload-pack` expects (a bare integer), not a human date string.
113    pub deepen_since: Option<String>,
114    /// Deepen history but stop at (exclude) commits reachable from these refs/oids
115    /// (`git fetch --shallow-exclude <ref>`). Each entry is sent as a wire
116    /// `deepen-not <ref>`.
117    pub deepen_not: Vec<String>,
118    /// Convert a shallow repository back into a complete one
119    /// (`git fetch --unshallow`). Drives the wire `deepen 0x7fffffff` request and
120    /// removes the local `shallow` boundaries that get reported as `unshallow`.
121    pub unshallow: bool,
122}
123
124impl Default for FetchOptions {
125    fn default() -> Self {
126        Self {
127            refspecs: Vec::new(),
128            negative_refspecs: Vec::new(),
129            tags: TagMode::default(),
130            prune: false,
131            dry_run: false,
132            depth: None,
133            deepen_since: None,
134            deepen_not: Vec::new(),
135            unshallow: false,
136        }
137    }
138}
139
140impl FetchOptions {
141    /// Whether this fetch carries any shallow/deepen request (an explicit
142    /// `depth`/`deepen-since`/`deepen-not`/`unshallow`). Note this does NOT cover
143    /// the "already shallow, fetching more of the same boundary" case — that is
144    /// driven by the on-disk `shallow` file, checked separately by the fetch
145    /// paths via [`crate::shallow::load_shallow_oids`].
146    #[must_use]
147    pub fn has_deepen_request(&self) -> bool {
148        self.depth.is_some()
149            || self
150                .deepen_since
151                .as_deref()
152                .is_some_and(|v| !v.trim().is_empty())
153            || self.deepen_not.iter().any(|v| !v.trim().is_empty())
154            || self.unshallow
155    }
156}
157
158/// The structured result of a fetch, ready for the embedder's ref-store apply.
159#[derive(Clone, Debug, Default)]
160pub struct FetchOutcome {
161    /// Per-ref resolved updates.
162    pub updates: Vec<RefUpdate>,
163    /// The remote's default branch (from `HEAD` symref), if known.
164    pub default_branch: Option<String>,
165    /// New shallow boundary commits the server reported (`shallow <oid>`), already
166    /// applied to the local `shallow` file. The commits' parents are intentionally
167    /// absent from the local object store after this fetch.
168    pub new_shallow: Vec<ObjectId>,
169    /// Commits the server reported as no longer shallow (`unshallow <oid>`), i.e.
170    /// boundaries removed from the local `shallow` file because their history is
171    /// now complete. Populated by a deepen / `--unshallow` fetch.
172    pub new_unshallow: Vec<ObjectId>,
173}
174
175/// A single ref update requested by a push.
176#[derive(Clone, Debug)]
177pub struct PushRefSpec {
178    /// The source object to push (`None` for a deletion).
179    pub src: Option<ObjectId>,
180    /// The destination ref on the remote (e.g. `refs/heads/main`).
181    pub dst: String,
182    /// Whether a non-fast-forward update is allowed.
183    pub force: bool,
184    /// Whether this update deletes the remote ref.
185    pub delete: bool,
186    /// Compare-and-swap expectation: the remote ref's current value must match
187    /// this (force-with-lease). `None` disables the value check.
188    pub expected_old: Option<ObjectId>,
189    /// Force-with-lease expectation that the remote ref does **not** currently
190    /// exist. When `true`, a push whose destination already exists on the remote
191    /// is rejected as stale (used for "create only" pushes whose lease is the
192    /// ref's absence). Independent of [`Self::expected_old`].
193    pub expect_absent: bool,
194}
195
196/// Options controlling a push.
197#[derive(Clone, Debug, Default)]
198pub struct PushOptions {
199    /// Apply all updates atomically (all-or-nothing).
200    pub atomic: bool,
201    /// Compute results without writing to the remote.
202    pub dry_run: bool,
203    /// Server-side push options to transmit (`git push --push-option <value>`).
204    ///
205    /// When non-empty, the negotiated capability list includes `push-options`
206    /// and one `push-option <value>` pkt-line per entry is written after the
207    /// ref-update command block and before the flush/pack. The remote exposes
208    /// these to its hooks via `GIT_PUSH_OPTION_COUNT` / `GIT_PUSH_OPTION_<n>`.
209    ///
210    /// If this is non-empty but the remote `git-receive-pack` does not advertise
211    /// the `push-options` capability, the push fails with
212    /// [`crate::error::Error::PushOptionsUnsupported`] (matching Git).
213    pub push_options: Vec<String>,
214}
215
216/// The structured result of a push. Reuses [`PushRefResult`] for per-ref status.
217#[derive(Clone, Debug, Default)]
218pub struct PushOutcome {
219    /// Per-ref resolved results (status, old/new oid, reason).
220    pub results: Vec<PushRefResult>,
221}
222
223/// Options controlling [`build_pack`].
224#[derive(Clone, Copy, Debug)]
225pub struct PackBuildOptions {
226    /// Build a thin pack: allow deltas whose base is reachable from the `haves`
227    /// (so present on the peer) but **not** itself emitted in the pack. The base
228    /// is referenced by `REF_DELTA` and the peer reconstructs the object from its
229    /// own copy. Requires [`Self::delta`] to have any effect.
230    pub thin: bool,
231    /// Emit delta-compressed objects (`OFS_DELTA`/`REF_DELTA`) for similar blobs
232    /// instead of whole objects. When `false` the builder emits whole objects
233    /// only (the phase-1 behavior).
234    pub delta: bool,
235    /// How many candidate bases (size-sorted neighbors) to consider per blob.
236    /// `0` disables in-pack delta selection. Mirrors Git's `--window`.
237    pub window: usize,
238    /// Cap delta chain length (number of edges). `0` stores all blobs whole.
239    /// Mirrors Git's `--depth`.
240    pub max_depth: usize,
241    /// Use `OFS_DELTA` (offset-relative base) when the base precedes the target
242    /// in the pack; otherwise `REF_DELTA` (base named by OID). Thin/external
243    /// bases always use `REF_DELTA` regardless of this flag.
244    pub use_ofs_delta: bool,
245    /// Honor delta islands (`pack.island` config) when selecting bases, so a
246    /// target only deltas against a base in a compatible (superset) island and
247    /// the base preference is biased toward objects living in dominating islands.
248    ///
249    /// Mirrors `git pack-objects --delta-islands`. When `false` (the default,
250    /// preserving the prior behavior) islands are ignored entirely — equivalent
251    /// to no `pack.island` config. Loading islands walks the ref graph, so this
252    /// only does work when the repository actually configures islands.
253    pub respect_islands: bool,
254    /// Reuse on-disk `REF_DELTA`/`OFS_DELTA` edges from existing packs when both
255    /// the target and its recorded base are in this pack, instead of recomputing
256    /// a fresh delta. Mirrors Git's `reuse_delta` window-reuse path.
257    ///
258    /// `false` (the default) preserves the prior behavior of always computing
259    /// fresh deltas. Reuse only applies to SHA-1 packs (the reuse helpers read
260    /// 20-byte index entries) and is skipped silently otherwise.
261    pub reuse_deltas: bool,
262}
263
264impl Default for PackBuildOptions {
265    fn default() -> Self {
266        Self {
267            thin: false,
268            delta: false,
269            window: 10,
270            max_depth: 50,
271            use_ofs_delta: true,
272            respect_islands: false,
273            reuse_deltas: false,
274        }
275    }
276}
277
278/// Build a v2 packfile containing exactly the objects reachable from `wants`
279/// but **not** reachable from `haves`, de-duplicated.
280///
281/// This is the negotiation-driven object selection that lets embedders avoid
282/// packing the entire reachable closure of a pushed tip (the 478 MB regression
283/// the spike hit). The walk is a BFS over commit parents and tree entries:
284///
285/// 1. Compute the object closure of `haves` (commits, their trees recursively,
286///    blobs, and annotated-tag targets). Descent stops at any object already in
287///    that closure.
288/// 2. Walk `wants` the same way, skipping any object in the `haves` closure, and
289///    collect every newly-reachable object.
290/// 3. Serialize the collected objects as whole (non-delta) entries into a valid
291///    PACK v2 stream, with the trailing checksum at the repository's hash width.
292///
293/// The produced bytes start with `PACK`, carry the exact object count, and
294/// re-parse cleanly with [`crate::pack::read_object_from_pack_bytes`].
295///
296/// # Errors
297///
298/// Returns an error if a required object is missing from `odb`, if an object
299/// fails to parse, or if the repository hash width is unsupported.
300pub fn build_pack(
301    odb: &Odb,
302    wants: &[ObjectId],
303    haves: &[ObjectId],
304    opts: &PackBuildOptions,
305) -> Result<Vec<u8>> {
306    // Objects already reachable from the remote's haves: never repack these, and
307    // stop descent into them. A `have` that is not present in this odb (e.g. a
308    // local-only commit named by a local tracking ref) simply prunes nothing, so
309    // missing haves are tolerated rather than erroring.
310    let have_closure = reachable_closure(odb, haves, &HashSet::new(), true)?;
311
312    // Objects reachable from wants but not from haves, in discovery order. A
313    // missing want IS an error (we were asked to pack an object we don't have).
314    let send = collect_reachable_excluding(odb, wants, &have_closure, false)?;
315
316    if !opts.delta {
317        // Phase-1 behavior: whole objects only. Correct and minimal in object
318        // count, not byte-optimal.
319        return serialize_pack(odb, &send);
320    }
321
322    // Delta path: pick blob deltas (within the pack, and — when `thin` — against
323    // bases the peer already holds), then serialize OFS/REF-delta entries.
324    let plan = plan_deltas(odb, &send, &have_closure, opts)?;
325    serialize_pack_with_deltas(odb, &plan, opts)
326}
327
328/// Compute the full object closure reachable from `roots`, stopping descent into
329/// any object already present in `stop`.
330fn reachable_closure(
331    odb: &Odb,
332    roots: &[ObjectId],
333    stop: &HashSet<ObjectId>,
334    skip_missing: bool,
335) -> Result<HashSet<ObjectId>> {
336    let mut seen = HashSet::new();
337    let order = collect_reachable_excluding(odb, roots, stop, skip_missing)?;
338    for oid in order {
339        seen.insert(oid);
340    }
341    Ok(seen)
342}
343
344/// BFS over `roots` collecting every reachable object (commits, trees, blobs,
345/// tag targets) that is not in `exclude`, returned in discovery order with no
346/// duplicates.
347///
348/// Discovery order keeps commits before the trees/blobs they introduce, which
349/// is a reasonable, deterministic pack ordering.
350fn collect_reachable_excluding(
351    odb: &Odb,
352    roots: &[ObjectId],
353    exclude: &HashSet<ObjectId>,
354    skip_missing: bool,
355) -> Result<Vec<ObjectId>> {
356    let mut visited: HashSet<ObjectId> = HashSet::new();
357    let mut ordered: Vec<ObjectId> = Vec::new();
358    let mut queue: VecDeque<ObjectId> = VecDeque::new();
359
360    let enqueue = |oid: ObjectId,
361                   queue: &mut VecDeque<ObjectId>,
362                   visited: &mut HashSet<ObjectId>,
363                   ordered: &mut Vec<ObjectId>|
364     -> bool {
365        if exclude.contains(&oid) {
366            return false;
367        }
368        if visited.insert(oid) {
369            ordered.push(oid);
370            queue.push_back(oid);
371            true
372        } else {
373            false
374        }
375    };
376
377    for &root in roots {
378        enqueue(root, &mut queue, &mut visited, &mut ordered);
379    }
380
381    while let Some(oid) = queue.pop_front() {
382        let obj = match odb.read(&oid) {
383            Ok(o) => o,
384            // A root/have absent from this odb cannot be traversed; with
385            // `skip_missing` it simply contributes nothing (no descent), instead
386            // of failing the whole pack build.
387            Err(_) if skip_missing => continue,
388            Err(e) => return Err(e),
389        };
390        match obj.kind {
391            ObjectKind::Commit => {
392                let commit = parse_commit(&obj.data)?;
393                for parent in commit.parents {
394                    enqueue(parent, &mut queue, &mut visited, &mut ordered);
395                }
396                enqueue(commit.tree, &mut queue, &mut visited, &mut ordered);
397            }
398            ObjectKind::Tree => {
399                for entry in parse_tree(&obj.data)? {
400                    // Skip submodule (gitlink) entries: the commit they name
401                    // lives in another object store and is not part of this pack.
402                    if entry.mode == 0o160000 {
403                        continue;
404                    }
405                    enqueue(entry.oid, &mut queue, &mut visited, &mut ordered);
406                }
407            }
408            ObjectKind::Tag => {
409                let tag = parse_tag(&obj.data)?;
410                enqueue(tag.object, &mut queue, &mut visited, &mut ordered);
411            }
412            ObjectKind::Blob => {}
413        }
414    }
415
416    Ok(ordered)
417}
418
419/// The pack object type code for a Git object kind (PACK v2 base types).
420fn pack_type_code(kind: ObjectKind) -> u8 {
421    match kind {
422        ObjectKind::Commit => 1,
423        ObjectKind::Tree => 2,
424        ObjectKind::Blob => 3,
425        ObjectKind::Tag => 4,
426    }
427}
428
429/// Append a PACK object header: 3-bit type + variable-length size (little-endian
430/// 7-bit groups, MSB = continuation). Lifted from the CLI pack writer.
431fn encode_pack_object_header(buf: &mut Vec<u8>, type_code: u8, payload_len: usize) {
432    let mut size = payload_len;
433    let first = ((type_code & 0x7) << 4) | (size & 0x0f) as u8;
434    size >>= 4;
435    if size > 0 {
436        buf.push(first | 0x80);
437        while size > 0 {
438            let b = (size & 0x7f) as u8;
439            size >>= 7;
440            buf.push(if size > 0 { b | 0x80 } else { b });
441        }
442    } else {
443        buf.push(first);
444    }
445}
446
447/// Serialize `oids` as a PACK v2 stream of whole (non-delta) objects, terminated
448/// by the trailing pack checksum at the repository hash width.
449fn serialize_pack(odb: &Odb, oids: &[ObjectId]) -> Result<Vec<u8>> {
450    let mut buf = Vec::new();
451    buf.extend_from_slice(b"PACK");
452    buf.extend_from_slice(&2u32.to_be_bytes());
453    let count = u32::try_from(oids.len())
454        .map_err(|_| Error::CorruptObject("pack object count exceeds u32".to_owned()))?;
455    buf.extend_from_slice(&count.to_be_bytes());
456
457    for oid in oids {
458        let obj = odb.read(oid)?;
459        encode_pack_object_header(&mut buf, pack_type_code(obj.kind), obj.data.len());
460        let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
461        enc.write_all(&obj.data).map_err(Error::Io)?;
462        let compressed = enc.finish().map_err(Error::Io)?;
463        buf.extend_from_slice(&compressed);
464    }
465
466    append_pack_trailer(&mut buf, odb.hash_algo());
467    Ok(buf)
468}
469
470/// Append the trailing pack checksum: the hash of everything written so far, at
471/// the repository's hash width (SHA-1 → 20 bytes, SHA-256 → 32 bytes).
472fn append_pack_trailer(buf: &mut Vec<u8>, algo: HashAlgo) {
473    match algo {
474        HashAlgo::Sha1 => {
475            let mut hasher = Sha1::new();
476            hasher.update(&*buf);
477            buf.extend_from_slice(&hasher.finalize());
478        }
479        HashAlgo::Sha256 => {
480            let mut hasher = Sha256::new();
481            hasher.update(&*buf);
482            buf.extend_from_slice(&hasher.finalize());
483        }
484    }
485}
486
487/// A single object to write, either whole or as a delta against a chosen base.
488struct PlannedEntry {
489    oid: ObjectId,
490    kind: ObjectKind,
491    /// Object payload, kept so the serializer can re-hash/compress without a
492    /// second odb read.
493    data: Vec<u8>,
494    /// `Some(base_oid)` when this entry is a (blob) delta against `base_oid`.
495    /// The base may be an in-pack object or — for thin packs — an external base
496    /// present only on the peer.
497    base: Option<ObjectId>,
498    /// A delta instruction stream reused verbatim from an existing on-disk pack
499    /// (Git's `reuse_delta`). When present, the serializer emits these bytes
500    /// instead of recomputing the delta against `base`. Only set for a delta
501    /// entry whose `base` matches the recorded on-disk base.
502    reused_delta: Option<Vec<u8>>,
503}
504
505/// The full delta plan: the ordered entries to emit plus the set of external
506/// (thin) bases that were referenced but deliberately not emitted.
507struct DeltaPlan {
508    entries: Vec<PlannedEntry>,
509    #[allow(dead_code)]
510    external_bases: HashSet<ObjectId>,
511}
512
513/// Length of the common prefix of `a` and `b`.
514fn common_prefix_len(a: &[u8], b: &[u8]) -> usize {
515    a.iter()
516        .zip(b.iter())
517        .take_while(|(left, right)| left == right)
518        .count()
519}
520
521/// Break delta chains longer than `max_depth` edges, mirroring Git's
522/// `break_delta_chains` modulo rule so re-indexing stays within `--depth`.
523fn apply_delta_depth_limit(map: &mut HashMap<ObjectId, ObjectId>, max_depth: usize) {
524    let keys: Vec<ObjectId> = map.keys().copied().collect();
525    let value_set: HashSet<ObjectId> = map.values().copied().collect();
526    let tips: Vec<ObjectId> = keys
527        .into_iter()
528        .filter(|k| !value_set.contains(k))
529        .collect();
530
531    let modulus = max_depth.saturating_add(1);
532    let mut snip: HashSet<ObjectId> = HashSet::new();
533
534    for tip in tips {
535        let mut chain: Vec<ObjectId> = Vec::new();
536        let mut cur = tip;
537        let mut seen = HashSet::new();
538        while seen.insert(cur) {
539            chain.push(cur);
540            let Some(&b) = map.get(&cur) else {
541                break;
542            };
543            cur = b;
544        }
545        let n = chain.len();
546        if n < 2 {
547            continue;
548        }
549        let mut total_depth = (n - 1) as u32;
550        for &oid in &chain {
551            let assigned = (total_depth as usize) % modulus;
552            total_depth = total_depth.saturating_sub(1);
553            if assigned == 0 {
554                snip.insert(oid);
555            }
556        }
557    }
558    for oid in snip {
559        map.remove(&oid);
560    }
561}
562
563/// Select blob deltas for `send` and produce an ordered emit plan.
564///
565/// A lift of the CLI's `optimize_blob_deltas`: a size-sorted prefix/LCP window
566/// heuristic over blobs, depth-limited via [`apply_delta_depth_limit`]. Trees and
567/// commits are emitted whole (matching the CLI's blob-only delta selection).
568/// Correctness (re-indexability) is preserved because every chosen base is acyclic
569/// and either in-pack or, for thin packs, peer-held.
570///
571/// Two optional refinements bring this closer to the CLI packer:
572///
573/// * **Delta islands** (`opts.respect_islands`): when `pack.island` config marks
574///   any ref, a target only deltas against a base in a compatible (superset)
575///   island ([`crate::delta_islands::DeltaIslands::in_same_island`]) and ties are
576///   broken toward bases in dominating islands
577///   ([`crate::delta_islands::DeltaIslands::delta_cmp`]). Islands default to
578///   inactive (the prior behavior).
579/// * **On-disk delta reuse** (`opts.reuse_deltas`): an existing
580///   `REF_DELTA`/`OFS_DELTA` edge whose base is also in this pack is reused
581///   verbatim ([`crate::pack::packed_ref_delta_reuse_slice`]) rather than
582///   recomputed, still subject to island rules.
583///
584/// When `opts.thin`, a blob may also delta against a base reachable from the
585/// peer's `haves` (`have_closure`) even though that base is not emitted; the base
586/// oid is recorded in [`DeltaPlan::external_bases`] and referenced via REF_DELTA.
587fn plan_deltas(
588    odb: &Odb,
589    send: &[ObjectId],
590    have_closure: &HashSet<ObjectId>,
591    opts: &PackBuildOptions,
592) -> Result<DeltaPlan> {
593    // Load every object once. The plan keeps payloads so the serializer needn't
594    // re-read; for the typical pack sizes this is the same data the whole-object
595    // path would touch anyway.
596    let mut objects: HashMap<ObjectId, Object> = HashMap::new();
597    for &oid in send {
598        objects.insert(oid, odb.read(&oid)?);
599    }
600
601    let in_pack: HashSet<ObjectId> = send.iter().copied().collect();
602
603    // Delta islands (`--delta-islands`): only loaded when requested AND a git dir
604    // is attached to the odb. An inactive island set (no `pack.island` config, or
605    // no matched ref) imposes no restriction, so the common case is unaffected.
606    let islands = load_islands_for_pack(odb, &in_pack, opts);
607
608    // target oid -> base oid (the object `target` deltas against).
609    let mut delta_to_base: HashMap<ObjectId, ObjectId> = HashMap::new();
610    // Deltas whose instruction stream is reused verbatim from an existing pack.
611    let mut reused: HashMap<ObjectId, Vec<u8>> = HashMap::new();
612    let mut external_bases: HashSet<ObjectId> = HashSet::new();
613
614    if opts.window > 0 && opts.max_depth > 0 {
615        // (1) On-disk delta reuse: for each in-pack blob whose existing on-disk
616        // representation is a delta against another in-pack object, reuse that
617        // edge directly. Island rules still apply (never base on an incompatible
618        // island). SHA-256 packs are skipped inside the reuse helper.
619        if opts.reuse_deltas && odb.hash_algo() == HashAlgo::Sha1 {
620            let objects_dir = odb.objects_dir();
621            for &t in send {
622                if objects[&t].kind != ObjectKind::Blob || objects[&t].data.is_empty() {
623                    continue;
624                }
625                if let Ok(Some((base, zdelta))) =
626                    crate::pack::packed_ref_delta_reuse_slice(objects_dir, &t, &in_pack)
627                {
628                    if base != t && in_pack.contains(&base) && islands.in_same_island(&t, &base) {
629                        delta_to_base.insert(t, base);
630                        reused.insert(t, zdelta);
631                    }
632                }
633            }
634        }
635
636        // Blobs in the pack, smallest-first (size-sorted window proximity).
637        let mut blobs: Vec<ObjectId> = send
638            .iter()
639            .copied()
640            .filter(|oid| objects[oid].kind == ObjectKind::Blob && !objects[oid].data.is_empty())
641            .collect();
642        blobs.sort_by_key(|oid| objects[oid].data.len());
643
644        // Optional thin bases: blobs present only on the peer that a packed blob
645        // could delta against. We load them lazily and cache by oid.
646        let mut external_blob_data: HashMap<ObjectId, Vec<u8>> = HashMap::new();
647        if opts.thin {
648            for &oid in have_closure {
649                if in_pack.contains(&oid) {
650                    continue;
651                }
652                if let Ok(obj) = odb.read(&oid) {
653                    if obj.kind == ObjectKind::Blob && !obj.data.is_empty() {
654                        external_blob_data.insert(oid, obj.data);
655                    }
656                }
657            }
658        }
659
660        for (i, &t) in blobs.iter().enumerate() {
661            // A reused on-disk delta already covers this target.
662            if delta_to_base.contains_key(&t) {
663                continue;
664            }
665            let t_data = &objects[&t].data;
666
667            // (base, common, base_len, external). When islands are active the
668            // selection additionally prefers a base in a dominating island via
669            // `delta_cmp`, matching the CLI's `island_delta_cmp` bias.
670            let mut best: Option<(ObjectId, usize, usize, bool)> = None;
671
672            // Consider larger in-pack blobs within the window (closest in size).
673            // `blobs` is ascending by size, so later entries are the larger bases.
674            let mut considered = 0usize;
675            for &b in blobs.iter().skip(i + 1) {
676                if considered >= opts.window {
677                    break;
678                }
679                considered += 1;
680                // Island rule: never base `t` on a blob in a non-superset island.
681                if !islands.in_same_island(&t, &b) {
682                    continue;
683                }
684                let b_data = &objects[&b].data;
685                if b_data.len() <= t_data.len() {
686                    continue;
687                }
688                let common = if b_data.starts_with(t_data) {
689                    t_data.len()
690                } else {
691                    common_prefix_len(t_data, b_data)
692                };
693                if common > 64 && common.saturating_mul(2) >= t_data.len() {
694                    let better = best.is_none_or(|(prev_b, bc, bl, _)| {
695                        // Prefer a strictly dominating island first (Git's
696                        // `island_delta_cmp`), then more common prefix, then the
697                        // smaller (closer-in-size) base.
698                        if islands.is_active() {
699                            let cmp = islands.delta_cmp(&b, &prev_b);
700                            if cmp < 0 {
701                                return true;
702                            }
703                            if cmp > 0 {
704                                return false;
705                            }
706                        }
707                        common > bc || (common == bc && b_data.len() < bl)
708                    });
709                    if better {
710                        best = Some((b, common, b_data.len(), false));
711                    }
712                }
713            }
714
715            // Thin: also consider peer-held external bases. An external base may
716            // be SMALLER than the target (the common "target extends an earlier
717            // version" case) — that is still a cheap delta and, because external
718            // bases are never emitted, can never form an in-pack chain cycle. We
719            // only switch to a thin base when no equally-good in-pack base exists.
720            if opts.thin {
721                for (&b, b_data) in &external_blob_data {
722                    if b == t {
723                        continue;
724                    }
725                    // External (peer-held) bases participate in island rules too.
726                    if !islands.in_same_island(&t, &b) {
727                        continue;
728                    }
729                    let common = common_prefix_len(t_data, b_data);
730                    if common > 64 && common.saturating_mul(2) >= t_data.len() {
731                        let better = best.is_none_or(|(_, bc, bl, ext)| {
732                            common > bc || (common == bc && ext && b_data.len() < bl)
733                        });
734                        if better {
735                            best = Some((b, common, b_data.len(), true));
736                        }
737                    }
738                }
739            }
740
741            if let Some((base, _, _, external)) = best {
742                delta_to_base.insert(t, base);
743                if external {
744                    external_bases.insert(base);
745                    if let Some(d) = external_blob_data.get(&base) {
746                        objects
747                            .entry(base)
748                            .or_insert_with(|| Object::new(ObjectKind::Blob, d.clone()));
749                    }
750                }
751            }
752        }
753
754        // Cap chain length. After snipping, any removed target reverts to whole.
755        apply_delta_depth_limit(&mut delta_to_base, opts.max_depth);
756
757        // A reused delta whose target was snipped (or whose base ceased to be the
758        // chosen base) reverts to a freshly-computed full/delta object.
759        reused.retain(|t, _| delta_to_base.contains_key(t));
760
761        // A base that is no longer referenced as an external base (because its
762        // only dependent was snipped) must not be counted as external.
763        external_bases.retain(|b| delta_to_base.values().any(|v| v == b));
764    }
765
766    // Emit in the original discovery order so commits precede their trees/blobs.
767    // For OFS_DELTA the serializer needs each base to appear before its target;
768    // discovery order already places a larger base blob no earlier than a smaller
769    // one only by coincidence, so the serializer falls back to REF_DELTA whenever
770    // the base has not yet been written.
771    let mut entries: Vec<PlannedEntry> = Vec::with_capacity(send.len());
772    for &oid in send {
773        let obj = &objects[&oid];
774        entries.push(PlannedEntry {
775            oid,
776            kind: obj.kind,
777            data: obj.data.clone(),
778            base: delta_to_base.get(&oid).copied(),
779            reused_delta: reused.get(&oid).cloned(),
780        });
781    }
782
783    Ok(DeltaPlan {
784        entries,
785        external_bases,
786    })
787}
788
789/// Load delta-island marks for the objects being packed, honoring
790/// `opts.respect_islands`. Returns an inactive (no-op) island set when islands
791/// are not requested, when the odb has no attached git directory, or when no
792/// `pack.island` regex matches a ref — so callers can always consult the result
793/// without a flag check.
794fn load_islands_for_pack(
795    odb: &Odb,
796    in_pack: &HashSet<ObjectId>,
797    opts: &PackBuildOptions,
798) -> crate::delta_islands::DeltaIslands {
799    if !opts.respect_islands {
800        return crate::delta_islands::DeltaIslands::default();
801    }
802    let Some(git_dir) = odb.config_git_dir() else {
803        return crate::delta_islands::DeltaIslands::default();
804    };
805    let Ok(repo) = crate::repo::Repository::open(git_dir, None) else {
806        return crate::delta_islands::DeltaIslands::default();
807    };
808    let cfg = crate::config::ConfigSet::load(Some(git_dir), true).unwrap_or_default();
809    crate::delta_islands::load_delta_islands(&repo, &cfg, in_pack)
810}
811
812/// Serialize a [`DeltaPlan`] into a PACK v2 stream.
813///
814/// Whole entries are written as base objects; delta entries are written as
815/// `OFS_DELTA` when the base is already in the pack at a known offset and
816/// `opts.use_ofs_delta` is set, otherwise `REF_DELTA` (which also covers thin /
817/// external bases that are never emitted).
818fn serialize_pack_with_deltas(
819    odb: &Odb,
820    plan: &DeltaPlan,
821    opts: &PackBuildOptions,
822) -> Result<Vec<u8>> {
823    let algo = odb.hash_algo();
824
825    let mut buf = Vec::new();
826    buf.extend_from_slice(b"PACK");
827    buf.extend_from_slice(&2u32.to_be_bytes());
828    let count = u32::try_from(plan.entries.len())
829        .map_err(|_| Error::CorruptObject("pack object count exceeds u32".to_owned()))?;
830    buf.extend_from_slice(&count.to_be_bytes());
831
832    // Payload of every emitted object, so a base reached later can be deltified
833    // and so we can compute deltas without another odb round-trip.
834    let payloads: HashMap<ObjectId, &[u8]> = plan
835        .entries
836        .iter()
837        .map(|e| (e.oid, e.data.as_slice()))
838        .collect();
839
840    let mut oid_to_offset: HashMap<ObjectId, u64> = HashMap::new();
841
842    for entry in &plan.entries {
843        let start = buf.len() as u64;
844        match entry.base {
845            None => {
846                encode_pack_object_header(&mut buf, pack_type_code(entry.kind), entry.data.len());
847                write_zlib(&mut buf, &entry.data)?;
848                oid_to_offset.insert(entry.oid, start);
849            }
850            Some(base_oid) => {
851                // A reused on-disk delta stream is emitted verbatim; otherwise
852                // compute a fresh delta against the resolved base payload.
853                let delta = if let Some(reused) = &entry.reused_delta {
854                    reused.clone()
855                } else {
856                    // Resolve the base payload: in-pack first, else (thin) from odb.
857                    let base_data: Vec<u8> = if let Some(d) = payloads.get(&base_oid) {
858                        d.to_vec()
859                    } else {
860                        odb.read(&base_oid)?.data
861                    };
862                    if entry.data.starts_with(&base_data) && entry.data.len() > base_data.len() {
863                        encode_prefix_extension_delta(&base_data, &entry.data)?
864                    } else {
865                        encode_lcp_delta(&base_data, &entry.data)?
866                    }
867                };
868
869                let in_pack_offset = oid_to_offset.get(&base_oid).copied();
870                if let Some(base_off) = in_pack_offset.filter(|_| opts.use_ofs_delta) {
871                    let dist = start.checked_sub(base_off).ok_or_else(|| {
872                        Error::CorruptObject("ofs-delta distance underflow".to_owned())
873                    })?;
874                    encode_pack_object_header(&mut buf, 6, delta.len());
875                    encode_ofs_delta_distance(&mut buf, dist);
876                } else {
877                    encode_pack_object_header(&mut buf, 7, delta.len());
878                    if base_oid.as_bytes().len() != algo.len() {
879                        return Err(Error::CorruptObject(
880                            "ref-delta base oid width mismatch".to_owned(),
881                        ));
882                    }
883                    buf.extend_from_slice(base_oid.as_bytes());
884                }
885                write_zlib(&mut buf, &delta)?;
886                oid_to_offset.insert(entry.oid, start);
887            }
888        }
889    }
890
891    append_pack_trailer(&mut buf, algo);
892    Ok(buf)
893}
894
895/// zlib-deflate `data` and append it to `buf`.
896fn write_zlib(buf: &mut Vec<u8>, data: &[u8]) -> Result<()> {
897    let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
898    enc.write_all(data).map_err(Error::Io)?;
899    let compressed = enc.finish().map_err(Error::Io)?;
900    buf.extend_from_slice(&compressed);
901    Ok(())
902}
903
904/// Encode an `OFS_DELTA` base distance (Git's offset varint). Lifted verbatim
905/// from the CLI pack writer's `encode_git_ofs_delta_distance`.
906fn encode_ofs_delta_distance(buf: &mut Vec<u8>, mut ofs: u64) {
907    let mut dheader = [0u8; 32];
908    let mut pos = dheader.len() - 1;
909    dheader[pos] = (ofs & 0x7f) as u8;
910    while {
911        ofs >>= 7;
912        ofs != 0
913    } {
914        pos -= 1;
915        ofs -= 1;
916        dheader[pos] = 0x80 | ((ofs & 0x7f) as u8);
917    }
918    buf.extend_from_slice(&dheader[pos..]);
919}
920
921/// Fetch refs and objects from one on-disk git repository into another, entirely
922/// in-process (no subprocess, no wire protocol).
923///
924/// This is the local / `file://` fetch path. It:
925///
926/// 1. Enumerates the remote's refs with [`crate::ls_remote::ls_remote`] and
927///    captures the remote `HEAD` symref for [`FetchOutcome::default_branch`].
928/// 2. Parses `opts.refspecs` with [`crate::refspec`] and, for each remote ref
929///    that matches a positive refspec (and is not excluded by a negative one),
930///    computes the destination local tracking ref and the wanted remote oid.
931///    [`TagMode`] adds tags: `All` brings every `refs/tags/*`, `Following` brings
932///    tags pointing at objects already being fetched, `None` skips `refs/tags/*`.
933/// 3. Copies the minimal set of objects (reachable from the wanted oids, stopping
934///    at objects already present locally) from the remote odb into the local odb
935///    via [`build_pack`] + [`crate::unpack_objects::unpack_objects`].
936/// 4. Classifies each ref update as [`UpdateMode`] (`New` / `UpToDate` /
937///    `FastForward` / `Forced` / `NonFastForwardRejected`) using local ancestry,
938///    and — unless `opts.dry_run` — writes the local tracking ref.
939/// 5. When `opts.prune` is set, deletes local tracking refs whose remote
940///    counterpart is gone, recording them as [`UpdateMode::DeletedMissing`].
941///
942/// Both repositories must use the same object hash algorithm; the hash width is
943/// threaded through [`Odb::hash_algo`] so SHA-256 repos work.
944///
945/// # Errors
946///
947/// Returns an error if either repository cannot be opened, if a refspec is
948/// invalid, if a required remote object cannot be read, or on I/O failure while
949/// writing objects or refs.
950//
951// TODO(phase: remote transports): `git://`, `http(s)`, and `ssh` fetch (wire
952// protocol handshake + negotiation + credential helpers) are out of scope here
953// and live in a later phase.
954pub fn fetch_local(
955    local_git_dir: &Path,
956    remote_git_dir: &Path,
957    opts: &FetchOptions,
958) -> Result<FetchOutcome> {
959    // Validate that the remote is actually a Git repository: a missing or
960    // non-repo path must error (e.g. cloning a bad source) rather than silently
961    // fetching nothing. A repo has an `objects` directory (bare or `.git`).
962    if !remote_git_dir.join("objects").is_dir() {
963        return Err(Error::Message(format!(
964            "could not find repository at '{}'",
965            remote_git_dir.display()
966        )));
967    }
968
969    let local_odb = open_odb(local_git_dir);
970    let remote_odb = open_odb(remote_git_dir);
971
972    // 1. Enumerate remote refs (with HEAD symref for the default branch).
973    let remote_entries = crate::ls_remote::ls_remote(
974        remote_git_dir,
975        &remote_odb,
976        &crate::ls_remote::Options {
977            symref: true,
978            ..Default::default()
979        },
980    )?;
981
982    let mut default_branch = None;
983    // remote ref name -> oid (excluding HEAD and peeled `^{}` entries).
984    let mut remote_refs: Vec<(String, ObjectId)> = Vec::new();
985    for entry in &remote_entries {
986        if entry.name == "HEAD" {
987            default_branch = entry
988                .symref_target
989                .as_ref()
990                .map(|t| t.strip_prefix("refs/heads/").unwrap_or(t).to_owned());
991            continue;
992        }
993        if entry.name.ends_with("^{}") {
994            continue;
995        }
996        remote_refs.push((entry.name.clone(), entry.oid));
997    }
998
999    // 2. Parse refspecs.
1000    let mut positive: Vec<RefspecItem> = Vec::new();
1001    let mut negatives: Vec<RefspecItem> = Vec::new();
1002    for spec in &opts.refspecs {
1003        let item = parse_fetch_refspec(spec)
1004            .map_err(|e| Error::Message(format!("invalid refspec '{spec}': {e}")))?;
1005        if item.negative {
1006            negatives.push(item);
1007        } else {
1008            positive.push(item);
1009        }
1010    }
1011    for spec in &opts.negative_refspecs {
1012        let item = parse_fetch_refspec(spec)
1013            .map_err(|e| Error::Message(format!("invalid negative refspec '{spec}': {e}")))?;
1014        negatives.push(item);
1015    }
1016
1017    // Compute the matched (remote_ref, local_ref, wanted_oid, force) set.
1018    // `local_ref == None` means "fetch but do not store" (empty dst).
1019    let mut matched: Vec<MatchedRef> = Vec::new();
1020    let mut matched_oids: HashSet<ObjectId> = HashSet::new();
1021    let mut seen_remote_ref: HashSet<String> = HashSet::new();
1022
1023    for (name, oid) in &remote_refs {
1024        if name.starts_with("refs/tags/") {
1025            // Tags are governed by TagMode below, not the head refspecs, unless
1026            // a refspec explicitly names them. Still allow an explicit refspec
1027            // match here; TagMode adds the rest.
1028        }
1029        if ref_excluded(name, &negatives) {
1030            continue;
1031        }
1032        if let Some(local_ref) = match_positive(name, &positive) {
1033            if seen_remote_ref.insert(name.clone()) {
1034                matched_oids.insert(*oid);
1035                matched.push(MatchedRef {
1036                    remote_ref: name.clone(),
1037                    local_ref,
1038                    oid: *oid,
1039                    force: refspecs_force(name, &positive),
1040                    is_tag: name.starts_with("refs/tags/"),
1041                });
1042            }
1043        }
1044    }
1045
1046    // TagMode: add tags. We need the closure of objects already being fetched to
1047    // decide "Following".
1048    apply_tag_mode(
1049        opts.tags,
1050        &remote_refs,
1051        &remote_odb,
1052        &negatives,
1053        &mut matched,
1054        &mut matched_oids,
1055        &mut seen_remote_ref,
1056    )?;
1057
1058    // 3. Determine wants (matched oids not present locally) and haves (current
1059    //    local tracking-ref tips) and copy the minimal object set.
1060    let wants: Vec<ObjectId> = matched_oids
1061        .iter()
1062        .copied()
1063        .filter(|oid| !local_odb.exists(oid))
1064        .collect();
1065
1066    let mut haves: Vec<ObjectId> = Vec::new();
1067    let mut have_seen: HashSet<ObjectId> = HashSet::new();
1068    for m in &matched {
1069        if let Some(local_ref) = &m.local_ref {
1070            if let Ok(old) = crate::refs::resolve_ref(local_git_dir, local_ref) {
1071                if have_seen.insert(old) {
1072                    haves.push(old);
1073                }
1074            }
1075        }
1076    }
1077
1078    if !wants.is_empty() && !opts.dry_run {
1079        let pack = build_pack(&remote_odb, &wants, &haves, &PackBuildOptions::default())?;
1080        let mut cursor = std::io::Cursor::new(pack);
1081        crate::unpack_objects::unpack_objects(
1082            &mut cursor,
1083            &local_odb,
1084            &crate::unpack_objects::UnpackOptions {
1085                quiet: true,
1086                ..Default::default()
1087            },
1088        )?;
1089    }
1090
1091    // 4. Classify and apply ref updates. Ancestry checks use the local repo,
1092    //    which now contains the fetched objects.
1093    let local_repo = if opts.dry_run {
1094        None
1095    } else {
1096        crate::repo::Repository::open(local_git_dir, None).ok()
1097    };
1098
1099    let mut updates: Vec<RefUpdate> = Vec::new();
1100
1101    // Prune BEFORE writing the new tips. A stale tracking ref stored as a file
1102    // (e.g. `refs/remotes/origin/a`) otherwise blocks creating a nested ref the
1103    // same fetch introduces (`refs/remotes/origin/a/b`) with a "File exists"
1104    // directory/file conflict (matches `git fetch --prune` ordering).
1105    if opts.prune {
1106        prune_tracking_refs(
1107            local_git_dir,
1108            &positive,
1109            &remote_refs,
1110            opts.dry_run,
1111            &mut updates,
1112        )?;
1113    }
1114
1115    for m in &matched {
1116        let Some(local_ref) = &m.local_ref else {
1117            // dst empty: fetched but not stored. Report as a no-store update.
1118            updates.push(RefUpdate {
1119                remote_ref: m.remote_ref.clone(),
1120                local_ref: None,
1121                old_oid: None,
1122                new_oid: Some(m.oid),
1123                mode: UpdateMode::NoChangeNeeded,
1124                note: Some("not stored (empty destination)".to_owned()),
1125            });
1126            continue;
1127        };
1128
1129        let old = crate::refs::resolve_ref(local_git_dir, local_ref).ok();
1130        let mode = classify_update(old.as_ref(), &m.oid, m.force, m.is_tag, local_repo.as_ref());
1131
1132        let write = matches!(
1133            mode,
1134            UpdateMode::New | UpdateMode::FastForward | UpdateMode::Forced
1135        );
1136        if write && !opts.dry_run {
1137            crate::refs::write_ref(local_git_dir, local_ref, &m.oid)?;
1138        }
1139
1140        updates.push(RefUpdate {
1141            remote_ref: m.remote_ref.clone(),
1142            local_ref: Some(local_ref.clone()),
1143            old_oid: old,
1144            new_oid: Some(m.oid),
1145            mode,
1146            note: None,
1147        });
1148    }
1149
1150    // The local / file:// path copies the exact object closure and never grafts,
1151    // so it neither introduces nor resolves shallow boundaries.
1152    Ok(FetchOutcome {
1153        updates,
1154        default_branch,
1155        new_shallow: Vec::new(),
1156        new_unshallow: Vec::new(),
1157    })
1158}
1159
1160/// Push refs and objects from one on-disk git repository into another, entirely
1161/// in-process (no subprocess, no wire protocol).
1162///
1163/// This is the local / `file://` push (send-pack) counterpart to
1164/// [`fetch_local`]. For each [`PushRefSpec`] it:
1165///
1166/// 1. Resolves the source oid from the LOCAL repo (for a non-delete update) and
1167///    reads the remote's current value of `dst`.
1168/// 2. Enforces the update rules and produces a [`PushRefResult`] with the right
1169///    [`crate::push_report::PushRefStatus`]:
1170///    * `expected_old` set and mismatching the remote's current value →
1171///      [`PushRefStatus::RejectStale`] (compare-and-swap / force-with-lease).
1172///    * deletion → succeed when present, or [`PushRefStatus::UpToDate`] when the
1173///      ref is already gone.
1174///    * non-fast-forward (remote current is not an ancestor of the source)
1175///      without `force` → [`PushRefStatus::RejectNonFastForward`]; with `force`
1176///      it is accepted and reported as forced.
1177///    * unchanged (remote already at the source) → [`PushRefStatus::UpToDate`].
1178///    * otherwise [`PushRefStatus::Ok`].
1179/// 3. For accepted non-delete updates, copies the minimal object closure from the
1180///    LOCAL odb into the REMOTE odb via [`build_pack`] +
1181///    [`crate::unpack_objects::unpack_objects`], excluding objects already
1182///    reachable from the remote's existing ref tips.
1183/// 4. Applies the ref change on the remote (unless `opts.dry_run`).
1184///
1185/// When `opts.atomic` is set and any ref is rejected, no ref or object is
1186/// written and every otherwise-accepted ref is reported as
1187/// [`PushRefStatus::AtomicPushFailed`].
1188///
1189/// Both repositories must use the same object hash algorithm; the hash width is
1190/// threaded through [`Odb::hash_algo`] so SHA-256 repos work.
1191///
1192/// # Errors
1193///
1194/// Returns an error if either repository cannot be opened, if a source object is
1195/// missing from the local odb, or on I/O failure while writing objects or refs.
1196//
1197// TODO(phase: remote transports): `git://`, `http(s)`, and `ssh` push
1198// (receive-pack handshake + report-status parsing + credential helpers) are out
1199// of scope here and live in a later phase.
1200pub fn push_local(
1201    local_git_dir: &Path,
1202    remote_git_dir: &Path,
1203    refs: &[PushRefSpec],
1204    opts: &PushOptions,
1205) -> Result<PushOutcome> {
1206    let local_odb = open_odb(local_git_dir);
1207    let remote_odb = open_odb(remote_git_dir);
1208
1209    // Ancestry (fast-forward) checks run against the LOCAL repo, where the source
1210    // commits live. A remote-current oid that is not reachable from the source is
1211    // simply "not an ancestor", which is the correct non-fast-forward verdict.
1212    let local_repo = crate::repo::Repository::open(local_git_dir, None).ok();
1213
1214    // The remote's existing ref tips become the `haves` for pack building, so the
1215    // copied object closure excludes everything the remote already has.
1216    let remote_have_tips: Vec<ObjectId> = crate::refs::list_refs(remote_git_dir, "refs/")?
1217        .into_iter()
1218        .map(|(_, oid)| oid)
1219        .collect();
1220
1221    // First pass: decide each ref's status without mutating anything.
1222    let mut decisions: Vec<PushDecision> = Vec::with_capacity(refs.len());
1223    for spec in refs {
1224        decisions.push(decide_push(
1225            spec,
1226            &local_odb,
1227            remote_git_dir,
1228            local_repo.as_ref(),
1229        )?);
1230    }
1231
1232    // Atomic: if any update would be rejected, apply none and demote the
1233    // otherwise-accepted updates to AtomicPushFailed.
1234    let any_rejected = decisions.iter().any(|d| d.result.status.is_error());
1235    if opts.atomic && any_rejected {
1236        for d in &mut decisions {
1237            if matches!(d.result.status, PushRefStatus::Ok) {
1238                d.result.status = PushRefStatus::AtomicPushFailed;
1239                d.apply = false;
1240            }
1241        }
1242        return Ok(PushOutcome {
1243            results: decisions.into_iter().map(|d| d.result).collect(),
1244        });
1245    }
1246
1247    // Second pass: apply accepted updates (copy objects, then move/delete refs).
1248    for d in &mut decisions {
1249        if !d.apply || opts.dry_run {
1250            continue;
1251        }
1252        match &d.action {
1253            PushAction::Update(src) => {
1254                let pack = build_pack(
1255                    &local_odb,
1256                    &[*src],
1257                    &remote_have_tips,
1258                    &PackBuildOptions::default(),
1259                )?;
1260                let mut cursor = std::io::Cursor::new(pack);
1261                crate::unpack_objects::unpack_objects(
1262                    &mut cursor,
1263                    &remote_odb,
1264                    &crate::unpack_objects::UnpackOptions {
1265                        quiet: true,
1266                        ..Default::default()
1267                    },
1268                )?;
1269                crate::refs::write_ref(remote_git_dir, &d.result.remote_ref, src)?;
1270            }
1271            PushAction::Delete => {
1272                crate::refs::delete_ref(remote_git_dir, &d.result.remote_ref)?;
1273            }
1274            PushAction::None => {}
1275        }
1276    }
1277
1278    Ok(PushOutcome {
1279        results: decisions.into_iter().map(|d| d.result).collect(),
1280    })
1281}
1282
1283/// What a single accepted push update does once applied.
1284enum PushAction {
1285    /// Copy the closure of `src` to the remote and move the ref to `src`.
1286    Update(ObjectId),
1287    /// Delete the remote ref.
1288    Delete,
1289    /// No mutation (up-to-date or rejected).
1290    None,
1291}
1292
1293/// A decided-but-not-yet-applied push update.
1294struct PushDecision {
1295    result: PushRefResult,
1296    action: PushAction,
1297    /// Whether the second pass should apply `action`.
1298    apply: bool,
1299}
1300
1301/// Decide the status of a single [`PushRefSpec`] without mutating either repo.
1302fn decide_push(
1303    spec: &PushRefSpec,
1304    local_odb: &Odb,
1305    remote_git_dir: &Path,
1306    local_repo: Option<&crate::repo::Repository>,
1307) -> Result<PushDecision> {
1308    let remote_current = crate::refs::resolve_ref(remote_git_dir, &spec.dst).ok();
1309
1310    // Up-to-date trumps every lease: pushing a non-delete to where the remote ref
1311    // already points is a no-op that succeeds even when the force-with-lease
1312    // expectation (a specific `expected_old` value, or `expect_absent`) does not
1313    // hold — "creating/moving a bookmark to the same place it already is is OK".
1314    // Must precede both the absence-lease and compare-and-swap checks below.
1315    if !spec.delete {
1316        if let Some(src) = spec.src {
1317            if remote_current == Some(src) {
1318                return Ok(PushDecision {
1319                    result: PushRefResult {
1320                        local_ref: None,
1321                        remote_ref: spec.dst.clone(),
1322                        old_oid: remote_current,
1323                        new_oid: Some(src),
1324                        forced: false,
1325                        deletion: false,
1326                        status: PushRefStatus::UpToDate,
1327                        message: None,
1328                    },
1329                    action: PushAction::None,
1330                    apply: false,
1331                });
1332            }
1333        }
1334    }
1335
1336    // Absence lease (force-with-lease that the ref not exist): once the value is
1337    // actually changing (handled above), a destination that already exists fails
1338    // the lease and is rejected as stale.
1339    if spec.expect_absent && remote_current.is_some() {
1340        return Ok(PushDecision {
1341            result: PushRefResult {
1342                local_ref: None,
1343                remote_ref: spec.dst.clone(),
1344                old_oid: remote_current,
1345                new_oid: spec.src,
1346                forced: false,
1347                deletion: spec.delete,
1348                status: PushRefStatus::RejectStale,
1349                message: Some("stale info".to_owned()),
1350            },
1351            action: PushAction::None,
1352            apply: false,
1353        });
1354    }
1355
1356    // Compare-and-swap (force-with-lease): the remote's current value must match
1357    // the caller's expectation, otherwise reject as stale. A `None` expectation
1358    // disables the value check.
1359    if let Some(expected) = spec.expected_old {
1360        if remote_current != Some(expected) {
1361            return Ok(PushDecision {
1362                result: PushRefResult {
1363                    local_ref: None,
1364                    remote_ref: spec.dst.clone(),
1365                    old_oid: remote_current,
1366                    new_oid: spec.src,
1367                    forced: false,
1368                    deletion: spec.delete,
1369                    status: PushRefStatus::RejectStale,
1370                    message: Some("stale info".to_owned()),
1371                },
1372                action: PushAction::None,
1373                apply: false,
1374            });
1375        }
1376    }
1377
1378    if spec.delete {
1379        let (status, action, apply) = match remote_current {
1380            Some(_) => (PushRefStatus::Ok, PushAction::Delete, true),
1381            None => (PushRefStatus::UpToDate, PushAction::None, false),
1382        };
1383        return Ok(PushDecision {
1384            result: PushRefResult {
1385                local_ref: None,
1386                remote_ref: spec.dst.clone(),
1387                old_oid: remote_current,
1388                new_oid: None,
1389                forced: false,
1390                deletion: true,
1391                status,
1392                message: None,
1393            },
1394            action,
1395            apply,
1396        });
1397    }
1398
1399    // Non-delete updates require a source object that exists locally.
1400    let Some(src) = spec.src else {
1401        return Err(Error::Message(format!(
1402            "push to '{}' has no source object and is not a deletion",
1403            spec.dst
1404        )));
1405    };
1406    if !local_odb.exists(&src) {
1407        return Err(Error::Message(format!(
1408            "source object {src} for '{}' is missing from the local object store",
1409            spec.dst
1410        )));
1411    }
1412
1413    // Unchanged: the remote is already at the source.
1414    if remote_current == Some(src) {
1415        return Ok(PushDecision {
1416            result: PushRefResult {
1417                local_ref: None,
1418                remote_ref: spec.dst.clone(),
1419                old_oid: remote_current,
1420                new_oid: Some(src),
1421                forced: false,
1422                deletion: false,
1423                status: PushRefStatus::UpToDate,
1424                message: None,
1425            },
1426            action: PushAction::None,
1427            apply: false,
1428        });
1429    }
1430
1431    // New ref: nothing on the remote yet — always allowed.
1432    let Some(old) = remote_current else {
1433        return Ok(PushDecision {
1434            result: PushRefResult {
1435                local_ref: None,
1436                remote_ref: spec.dst.clone(),
1437                old_oid: None,
1438                new_oid: Some(src),
1439                forced: false,
1440                deletion: false,
1441                status: PushRefStatus::Ok,
1442                message: None,
1443            },
1444            action: PushAction::Update(src),
1445            apply: true,
1446        });
1447    };
1448
1449    // Existing ref: fast-forward when the remote's current commit is an ancestor
1450    // of the source. Otherwise it is a non-fast-forward update, allowed only with
1451    // force (reported as forced).
1452    let is_ff = local_repo
1453        .map(|r| crate::merge_base::is_ancestor(r, old, src).unwrap_or(false))
1454        .unwrap_or(false);
1455
1456    if is_ff {
1457        Ok(PushDecision {
1458            result: PushRefResult {
1459                local_ref: None,
1460                remote_ref: spec.dst.clone(),
1461                old_oid: Some(old),
1462                new_oid: Some(src),
1463                forced: false,
1464                deletion: false,
1465                status: PushRefStatus::Ok,
1466                message: None,
1467            },
1468            action: PushAction::Update(src),
1469            apply: true,
1470        })
1471    } else if spec.force {
1472        Ok(PushDecision {
1473            result: PushRefResult {
1474                local_ref: None,
1475                remote_ref: spec.dst.clone(),
1476                old_oid: Some(old),
1477                new_oid: Some(src),
1478                forced: true,
1479                deletion: false,
1480                status: PushRefStatus::Ok,
1481                message: None,
1482            },
1483            action: PushAction::Update(src),
1484            apply: true,
1485        })
1486    } else {
1487        Ok(PushDecision {
1488            result: PushRefResult {
1489                local_ref: None,
1490                remote_ref: spec.dst.clone(),
1491                old_oid: Some(old),
1492                new_oid: Some(src),
1493                forced: false,
1494                deletion: false,
1495                status: PushRefStatus::RejectNonFastForward,
1496                message: Some("non-fast-forward".to_owned()),
1497            },
1498            action: PushAction::None,
1499            apply: false,
1500        })
1501    }
1502}
1503
1504/// A remote ref selected for fetch, with its computed local destination.
1505pub(crate) struct MatchedRef {
1506    pub(crate) remote_ref: String,
1507    /// Destination local tracking ref, or `None` for an empty (no-store) dst.
1508    pub(crate) local_ref: Option<String>,
1509    pub(crate) oid: ObjectId,
1510    pub(crate) force: bool,
1511    pub(crate) is_tag: bool,
1512}
1513
1514/// Open an [`Odb`] for a git directory, attaching the git dir so `hash_algo`
1515/// (and MIDX config) resolve correctly.
1516pub(crate) fn open_odb(git_dir: &Path) -> Odb {
1517    Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.to_path_buf())
1518}
1519
1520/// Match a ref name against the positive refspecs, returning the destination
1521/// local ref name (`Some(name)`), `None`+stored=false collapsed: returns
1522/// `Some(Some(dst))` to store, `Some(None)` to fetch-without-store, or `None`
1523/// when no positive refspec matches.
1524pub(crate) fn match_positive(refname: &str, positive: &[RefspecItem]) -> Option<Option<String>> {
1525    for item in positive {
1526        let Some(src) = item.src.as_deref() else {
1527            continue;
1528        };
1529        if let Some(dst) = apply_refspec(src, item.dst.as_deref(), refname) {
1530            // Empty dst means "fetch but do not store".
1531            if dst.is_empty() {
1532                return Some(None);
1533            }
1534            return Some(Some(dst));
1535        }
1536    }
1537    None
1538}
1539
1540/// Whether any positive refspec matching `refname` requested force (`+`).
1541pub(crate) fn refspecs_force(refname: &str, positive: &[RefspecItem]) -> bool {
1542    positive.iter().any(|item| {
1543        item.force
1544            && item
1545                .src
1546                .as_deref()
1547                .is_some_and(|src| apply_refspec(src, item.dst.as_deref(), refname).is_some())
1548    })
1549}
1550
1551/// Whether `refname` is excluded by any negative refspec.
1552pub(crate) fn ref_excluded(refname: &str, negatives: &[RefspecItem]) -> bool {
1553    negatives.iter().any(|item| {
1554        item.src
1555            .as_deref()
1556            .is_some_and(|src| glob_matches(src, refname))
1557    })
1558}
1559
1560/// Apply a `<src>[:<dst>]` refspec to `refname`, returning the destination ref.
1561///
1562/// Supports a single `*` wildcard (Git's fetch refspec form). When `dst` is
1563/// `None` the destination equals the matched source (rare for tracking fetches);
1564/// when `dst` is `Some("")` the empty string is returned (fetch-without-store).
1565fn apply_refspec(src: &str, dst: Option<&str>, refname: &str) -> Option<String> {
1566    match src.find('*') {
1567        Some(star) => {
1568            let prefix = &src[..star];
1569            let suffix = &src[star + 1..];
1570            if !refname.starts_with(prefix)
1571                || !refname.ends_with(suffix)
1572                || refname.len() < prefix.len() + suffix.len()
1573            {
1574                return None;
1575            }
1576            let middle = &refname[prefix.len()..refname.len() - suffix.len()];
1577            match dst {
1578                None => Some(refname.to_owned()),
1579                Some("") => Some(String::new()),
1580                Some(d) => Some(d.replacen('*', middle, 1)),
1581            }
1582        }
1583        None => {
1584            if src != refname {
1585                return None;
1586            }
1587            match dst {
1588                None => Some(refname.to_owned()),
1589                Some("") => Some(String::new()),
1590                Some(d) => Some(d.to_owned()),
1591            }
1592        }
1593    }
1594}
1595
1596/// Whether `pattern` (a refspec src side, possibly with one `*`) matches `refname`.
1597fn glob_matches(pattern: &str, refname: &str) -> bool {
1598    match pattern.find('*') {
1599        Some(star) => {
1600            let prefix = &pattern[..star];
1601            let suffix = &pattern[star + 1..];
1602            refname.starts_with(prefix)
1603                && refname.ends_with(suffix)
1604                && refname.len() >= prefix.len() + suffix.len()
1605        }
1606        None => pattern == refname,
1607    }
1608}
1609
1610/// Add tags to the matched set according to [`TagMode`].
1611#[allow(clippy::too_many_arguments)]
1612pub(crate) fn apply_tag_mode(
1613    mode: TagMode,
1614    remote_refs: &[(String, ObjectId)],
1615    remote_odb: &Odb,
1616    negatives: &[RefspecItem],
1617    matched: &mut Vec<MatchedRef>,
1618    matched_oids: &mut HashSet<ObjectId>,
1619    seen_remote_ref: &mut HashSet<String>,
1620) -> Result<()> {
1621    if mode == TagMode::None {
1622        return Ok(());
1623    }
1624
1625    // For Following we need the set of objects reachable from the already-matched
1626    // (non-tag) refs, so we can keep tags pointing into that closure.
1627    let following_closure: HashSet<ObjectId> = if mode == TagMode::Following {
1628        let roots: Vec<ObjectId> = matched.iter().map(|m| m.oid).collect();
1629        reachable_closure(remote_odb, &roots, &HashSet::new(), true)?
1630    } else {
1631        HashSet::new()
1632    };
1633
1634    for (name, oid) in remote_refs {
1635        if !name.starts_with("refs/tags/") {
1636            continue;
1637        }
1638        if seen_remote_ref.contains(name) || ref_excluded(name, negatives) {
1639            continue;
1640        }
1641        let keep = match mode {
1642            TagMode::All => true,
1643            TagMode::Following => {
1644                // Keep when the tag (or what it peels to) is in the fetched
1645                // closure. Peel annotated tags to their target.
1646                let peeled = peel_tag_target(remote_odb, *oid)?;
1647                following_closure.contains(oid) || following_closure.contains(&peeled)
1648            }
1649            TagMode::None => false,
1650        };
1651        if keep {
1652            seen_remote_ref.insert(name.clone());
1653            matched_oids.insert(*oid);
1654            matched.push(MatchedRef {
1655                remote_ref: name.clone(),
1656                local_ref: Some(name.clone()),
1657                oid: *oid,
1658                force: false,
1659                is_tag: true,
1660            });
1661        }
1662    }
1663    Ok(())
1664}
1665
1666/// Peel an (annotated) tag to the non-tag object it ultimately points at.
1667/// Returns the input oid unchanged for non-tag objects or on read failure.
1668fn peel_tag_target(odb: &Odb, oid: ObjectId) -> Result<ObjectId> {
1669    let mut current = oid;
1670    for _ in 0..16 {
1671        let obj = match odb.read(&current) {
1672            Ok(o) => o,
1673            Err(_) => return Ok(current),
1674        };
1675        if obj.kind != ObjectKind::Tag {
1676            return Ok(current);
1677        }
1678        current = parse_tag(&obj.data)?.object;
1679    }
1680    Ok(current)
1681}
1682
1683/// Classify a single ref update into an [`UpdateMode`].
1684pub(crate) fn classify_update(
1685    old: Option<&ObjectId>,
1686    new: &ObjectId,
1687    force: bool,
1688    is_tag: bool,
1689    repo: Option<&crate::repo::Repository>,
1690) -> UpdateMode {
1691    let Some(old) = old else {
1692        return UpdateMode::New;
1693    };
1694    if old == new {
1695        return UpdateMode::UpToDate;
1696    }
1697    // Fast-forward when old is an ancestor of new (commit history only).
1698    let ff = repo
1699        .map(|r| crate::merge_base::is_ancestor(r, *old, *new).unwrap_or(false))
1700        .unwrap_or(false);
1701    if ff && !is_tag {
1702        return UpdateMode::FastForward;
1703    }
1704    if force {
1705        return UpdateMode::Forced;
1706    }
1707    if is_tag {
1708        return UpdateMode::TagUpdateRejected;
1709    }
1710    UpdateMode::NonFastForwardRejected
1711}
1712
1713/// Delete local tracking refs whose remote counterpart no longer exists.
1714///
1715/// A local tracking ref is a prune candidate when it lives under the destination
1716/// namespace of some positive wildcard refspec and no current remote ref maps to
1717/// it. Matches `git fetch --prune` for the common `refs/remotes/<remote>/*` case.
1718pub(crate) fn prune_tracking_refs(
1719    local_git_dir: &Path,
1720    positive: &[RefspecItem],
1721    remote_refs: &[(String, ObjectId)],
1722    dry_run: bool,
1723    updates: &mut Vec<RefUpdate>,
1724) -> Result<()> {
1725    // Set of local tracking refs that the current remote justifies.
1726    let mut live: HashSet<String> = HashSet::new();
1727    for (name, _) in remote_refs {
1728        if let Some(Some(dst)) = match_positive(name, positive) {
1729            live.insert(dst);
1730        }
1731    }
1732
1733    let mut pruned: HashMap<String, ObjectId> = HashMap::new();
1734    for item in positive {
1735        let Some(dst) = item.dst.as_deref() else {
1736            continue;
1737        };
1738        if let Some(star) = dst.find('*') {
1739            // Wildcard refspec: enumerate existing local refs under its
1740            // destination prefix and prune those the current remote no longer
1741            // justifies.
1742            let prefix = &dst[..star];
1743            for (name, oid) in crate::refs::list_refs(local_git_dir, prefix)? {
1744                if !name.starts_with(prefix) {
1745                    continue;
1746                }
1747                if !live.contains(&name) {
1748                    pruned.entry(name).or_insert(oid);
1749                }
1750            }
1751        } else if !live.contains(dst) {
1752            // Exact refspec (e.g. `refs/heads/a2:refs/remotes/origin/a2`): when
1753            // the source ref is gone from the remote, `dst` is absent from `live`,
1754            // so prune the tracking ref if it still exists locally. This is the
1755            // explicit `git fetch <remote> <branch>` / `--prune` deletion case.
1756            if let Ok(oid) = crate::refs::resolve_ref(local_git_dir, dst) {
1757                pruned.entry(dst.to_owned()).or_insert(oid);
1758            }
1759        }
1760    }
1761
1762    for (name, oid) in pruned {
1763        if !dry_run {
1764            crate::refs::delete_ref(local_git_dir, &name)?;
1765        }
1766        updates.push(RefUpdate {
1767            remote_ref: String::new(),
1768            local_ref: Some(name),
1769            old_oid: Some(oid),
1770            new_oid: None,
1771            mode: UpdateMode::DeletedMissing,
1772            note: Some("pruned (gone on remote)".to_owned()),
1773        });
1774    }
1775    Ok(())
1776}