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