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