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(¤t) {
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}