Skip to main content

sley_remote/
local.rs

1//! In-process `file://` upload-pack / receive-pack server.
2//!
3//! These are the transport-independent cores behind `git upload-pack` /
4//! `git receive-pack` and the local fetch/push paths: given a `git_dir`, an
5//! [`ObjectFormat`], and a decoded request, they read/write refs and objects
6//! through [`sley_refs`]/[`sley_odb`] and run the [`sley_protocol`] server logic.
7//! They take everything as explicit parameters and never touch process-global
8//! state, argument parsing, or stdout/stderr, so the CLI's `cmd_upload_pack` /
9//! `cmd_receive_pack` stdio wrappers and the `fetch`/`push` orchestration can
10//! call them, and an embedder can drive them directly.
11
12use std::collections::{HashMap, HashSet, VecDeque};
13use std::path::Path;
14
15use sley_core::{Capability, GitError, ObjectFormat, ObjectId, Result};
16use sley_object::{Commit, ObjectType, Tag};
17use sley_odb::{
18    FileObjectDatabase, ObjectReader, RawPackInstallOptions, build_and_install_reachable_pack,
19    build_and_install_reachable_pack_filtered, build_reachable_pack, collect_reachable_object_ids,
20};
21use sley_protocol::{
22    PKT_LINE_MAX_PAYLOAD_LEN, ProtocolV2FetchShallowInfo, ReceivePackFeatures,
23    ReceivePackPushRequest, ReceivePackReportStatus, ReceivePackRequest, RefAdvertisement,
24    SideBandChannel, SideBandPacket, UploadPackFeatures, UploadPackNegotiationRequest,
25    UploadPackPackfileResponse, UploadPackRawPackfileResponse, UploadPackRequest,
26    apply_receive_pack_push_request, build_upload_pack_raw_packfile_response,
27    encode_receive_pack_features, encode_upload_pack_features,
28    read_upload_pack_negotiation_request, read_upload_pack_request,
29    write_upload_pack_negotiation_request, write_upload_pack_request,
30};
31use sley_refs::{DeleteRef, FileRefStore, Ref, RefPrecondition, RefTarget};
32
33/// The all-zero object id for `format`, used for the synthetic
34/// `capabilities^{}` advertisement when a repository has no refs.
35fn zero_oid(format: ObjectFormat) -> Result<ObjectId> {
36    Ok(ObjectId::null(format))
37}
38
39/// Resolve a (possibly symbolic) ref target to its object id, following up to
40/// five levels of symbolic indirection, returning the first symbolic name seen.
41fn resolve_for_each_ref_target(
42    store: &FileRefStore,
43    reference: &Ref,
44) -> Result<Option<(ObjectId, Option<String>)>> {
45    let mut target = reference.target.clone();
46    let mut symref = None;
47    for _ in 0..5 {
48        match target {
49            RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
50            RefTarget::Symbolic(name) => {
51                symref.get_or_insert_with(|| name.clone());
52                let Some(next) = store.read_ref(&name)? else {
53                    return Ok(None);
54                };
55                target = next;
56            }
57        }
58    }
59    Ok(None)
60}
61
62/// The upload-pack capabilities advertised for the repository at `git_dir`:
63/// the object format, side-band-64k, and a `HEAD` symref hint if present.
64pub fn upload_pack_features(git_dir: &Path, format: ObjectFormat) -> Result<UploadPackFeatures> {
65    let store = FileRefStore::new(git_dir, format);
66    let mut symrefs = Vec::new();
67    if let Some(RefTarget::Symbolic(target)) = store.read_ref("HEAD")? {
68        symrefs.push(format!("HEAD:{target}"));
69    }
70    Ok(UploadPackFeatures {
71        object_format: Some(format),
72        side_band_64k: true,
73        symrefs,
74        ..UploadPackFeatures::default()
75    })
76}
77
78/// Whether the client negotiated a side-band channel for the packfile response.
79pub fn upload_pack_request_uses_sideband(request: &UploadPackRequest) -> bool {
80    request
81        .capabilities
82        .iter()
83        .any(|capability| matches!(capability.name.as_str(), "side-band" | "side-band-64k"))
84}
85
86/// Re-frame a raw packfile response as side-band data packets, chunked to the
87/// pkt-line payload limit (less the one-byte channel prefix).
88pub fn upload_pack_sideband_response(
89    response: UploadPackRawPackfileResponse,
90) -> UploadPackPackfileResponse {
91    let mut sideband = Vec::new();
92    let chunk_len = PKT_LINE_MAX_PAYLOAD_LEN - 1;
93    for chunk in response.packfile.chunks(chunk_len) {
94        sideband.push(SideBandPacket {
95            channel: SideBandChannel::Data,
96            data: chunk.to_vec(),
97        });
98    }
99    UploadPackPackfileResponse {
100        acknowledgments: response.acknowledgments,
101        sideband,
102    }
103}
104
105/// Encode `features` into the leading ref advertisement's capability list,
106/// inserting a synthetic `capabilities^{}` entry when there are no refs.
107pub fn attach_upload_pack_capabilities(
108    advertisements: &mut Vec<RefAdvertisement>,
109    format: ObjectFormat,
110    features: &UploadPackFeatures,
111) -> Result<()> {
112    let capabilities = encode_upload_pack_features(features)?;
113    if let Some(first) = advertisements.first_mut() {
114        first.capabilities = capabilities;
115    } else {
116        advertisements.push(RefAdvertisement {
117            oid: zero_oid(format)?,
118            name: "capabilities^{}".into(),
119            capabilities,
120        });
121    }
122    Ok(())
123}
124
125/// Serve an upload-pack request from the repository at `git_dir`: build the
126/// packfile that carries every reachable object the client `wants` but does not
127/// already `haves`, framed as a raw (non-side-band) response.
128pub fn upload_pack_from_local_repository(
129    git_dir: &Path,
130    format: ObjectFormat,
131    features: &UploadPackFeatures,
132    request: UploadPackRequest,
133    haves: HashSet<ObjectId>,
134) -> Result<UploadPackRawPackfileResponse> {
135    let db = FileObjectDatabase::from_git_dir(git_dir, format);
136    build_upload_pack_raw_packfile_response(
137        features,
138        request,
139        haves,
140        |oid| db.contains(oid),
141        |wants, known_haves| {
142            let excluded = collect_reachable_object_ids(&db, format, known_haves)?;
143            build_reachable_pack(&db, format, wants, &excluded)
144                .map(|pack| pack.map(|pack| pack.pack))
145        },
146    )
147}
148
149/// The receive-pack capabilities advertised for a local repository: report
150/// status, ref deletion, ofs-delta, push-options, quiet, and the object format.
151pub fn receive_pack_features(format: ObjectFormat) -> ReceivePackFeatures {
152    ReceivePackFeatures {
153        report_status: true,
154        delete_refs: true,
155        ofs_delta: true,
156        push_options: true,
157        quiet: true,
158        object_format: Some(format),
159        ..ReceivePackFeatures::default()
160    }
161}
162
163/// Whether the client negotiated `push-options` (so the caller must read the
164/// push-option section that follows the command list).
165pub fn receive_pack_request_uses_push_options(request: &ReceivePackRequest) -> bool {
166    request
167        .capabilities
168        .iter()
169        .any(|capability| capability.name == "push-options")
170}
171
172/// Encode `features` into the leading ref advertisement's capability list,
173/// inserting a synthetic `capabilities^{}` entry when there are no refs.
174pub fn attach_receive_pack_capabilities(
175    advertisements: &mut Vec<RefAdvertisement>,
176    format: ObjectFormat,
177    features: &ReceivePackFeatures,
178) -> Result<()> {
179    let capabilities = encode_receive_pack_features(features)?;
180    if let Some(first) = advertisements.first_mut() {
181        first.capabilities = capabilities;
182    } else {
183        advertisements.push(RefAdvertisement {
184            oid: zero_oid(format)?,
185            name: "capabilities^{}".into(),
186            capabilities,
187        });
188    }
189    Ok(())
190}
191
192/// Apply a receive-pack push to the repository at `remote_git_dir`: install the
193/// incoming packfile and execute the ref creations/updates/deletions, returning
194/// the report-status describing what happened.
195pub fn receive_pack_into_local_repository(
196    remote_git_dir: &Path,
197    format: ObjectFormat,
198    request: &ReceivePackPushRequest,
199) -> Result<ReceivePackReportStatus> {
200    let remote_store = FileRefStore::new(remote_git_dir, format);
201    let remote_db = FileObjectDatabase::from_git_dir(remote_git_dir, format);
202    apply_receive_pack_push_request(
203        &receive_pack_features(format),
204        request,
205        |name| match remote_store.read_ref(name)? {
206            Some(RefTarget::Direct(oid)) => Ok(Some(oid)),
207            Some(RefTarget::Symbolic(_)) | None => Ok(None),
208        },
209        |packfile| remote_db.install_raw_pack(packfile).map(|_| ()),
210        |oid| remote_db.contains(oid),
211        |commands| {
212            let mut tx = remote_store.transaction();
213            for command in commands {
214                let precondition = if command.old_id.is_null() {
215                    RefPrecondition::MustNotExist
216                } else {
217                    RefPrecondition::MustExistAndMatch(RefTarget::Direct(command.old_id))
218                };
219                tx.update_to(
220                    command.name.clone(),
221                    RefTarget::Direct(command.new_id),
222                    precondition,
223                    None,
224                );
225            }
226            tx.commit()
227        },
228        |command| {
229            remote_store
230                .delete_ref_checked(DeleteRef {
231                    name: command.name.clone(),
232                    expected_old: (!command.old_id.is_null()).then_some(command.old_id),
233                    reflog: None,
234                })
235                .map(|_| ())
236                .map_err(|err| GitError::Transaction(err.to_string()))
237        },
238    )
239}
240
241/// Apply a local receive-pack request whose pack can be built from `source_db`
242/// after receive-pack preflight checks pass.
243///
244/// This keeps local push on the same validation path as raw receive-pack while
245/// avoiding a raw-pack round trip: the install closure builds the reachable
246/// pack and installs the generated pack/index directly.
247pub fn receive_pack_reachable_pack_into_local_repository(
248    remote_git_dir: &Path,
249    format: ObjectFormat,
250    request: &ReceivePackPushRequest,
251    source_db: &FileObjectDatabase,
252    starts: Vec<ObjectId>,
253    excluded: HashSet<ObjectId>,
254) -> Result<ReceivePackReportStatus> {
255    let remote_store = FileRefStore::new(remote_git_dir, format);
256    let remote_db = FileObjectDatabase::from_git_dir(remote_git_dir, format);
257    let mut starts = Some(starts);
258    apply_receive_pack_push_request(
259        &receive_pack_features(format),
260        request,
261        |name| match remote_store.read_ref(name)? {
262            Some(RefTarget::Direct(oid)) => Ok(Some(oid)),
263            Some(RefTarget::Symbolic(_)) | None => Ok(None),
264        },
265        |_| {
266            let starts = starts.take().ok_or_else(|| {
267                GitError::InvalidFormat("receive-pack attempted to install pack twice".into())
268            })?;
269            build_and_install_reachable_pack(
270                source_db,
271                &remote_db,
272                format,
273                starts,
274                &excluded,
275                RawPackInstallOptions { promisor: false },
276            )?;
277            Ok(())
278        },
279        |oid| remote_db.contains(oid),
280        |commands| {
281            let mut tx = remote_store.transaction();
282            for command in commands {
283                let precondition = if command.old_id.is_null() {
284                    RefPrecondition::MustNotExist
285                } else {
286                    RefPrecondition::MustExistAndMatch(RefTarget::Direct(command.old_id))
287                };
288                tx.update_to(
289                    command.name.clone(),
290                    RefTarget::Direct(command.new_id),
291                    precondition,
292                    None,
293                );
294            }
295            tx.commit()
296        },
297        |command| {
298            remote_store
299                .delete_ref_checked(DeleteRef {
300                    name: command.name.clone(),
301                    expected_old: (!command.old_id.is_null()).then_some(command.old_id),
302                    reflog: None,
303                })
304                .map(|_| ())
305                .map_err(|err| GitError::Transaction(err.to_string()))
306        },
307    )
308}
309
310/// The ref advertisements a local repository would send to a fetching client:
311/// `HEAD` (if resolvable) followed by every ref, each resolved to its object id.
312pub fn local_fetch_advertisements(
313    git_dir: &Path,
314    format: ObjectFormat,
315) -> Result<Vec<RefAdvertisement>> {
316    let store = FileRefStore::new(git_dir, format);
317    let mut advertisements = Vec::new();
318    if let Some(target) = store.read_ref("HEAD")? {
319        let reference = Ref {
320            name: "HEAD".to_string(),
321            target,
322        };
323        if let Some((oid, _)) = resolve_for_each_ref_target(&store, &reference)? {
324            advertisements.push(RefAdvertisement {
325                oid,
326                name: reference.name,
327                capabilities: Vec::new(),
328            });
329        }
330    }
331    for reference in store.list_refs()? {
332        let Some((oid, _)) = resolve_for_each_ref_target(&store, &reference)? else {
333            continue;
334        };
335        advertisements.push(RefAdvertisement {
336            oid,
337            name: reference.name,
338            capabilities: Vec::new(),
339        });
340    }
341    Ok(advertisements)
342}
343
344/// The object ids the local repository can offer as `have`s during negotiation:
345/// the deduplicated tips of its own advertisements.
346pub fn local_have_oids(git_dir: &Path, format: ObjectFormat) -> Result<Vec<ObjectId>> {
347    let mut seen = HashSet::new();
348    let mut haves = Vec::new();
349    for advertisement in local_fetch_advertisements(git_dir, format)? {
350        if seen.insert(advertisement.oid) {
351            haves.push(advertisement.oid);
352        }
353    }
354    Ok(haves)
355}
356
357/// The in-process upload-pack's plan for a `deepen` (shallow) local fetch:
358/// which `shallow`/`unshallow` updates to report, which commits the pack walk
359/// must stop at, and which extra tips become packable because the client's
360/// boundary moved.
361///
362/// Mirrors upstream `upload-pack.c::deepen` + `shallow.c::get_shallow_commits`.
363#[derive(Debug, Clone)]
364pub struct LocalDeepenPlan {
365    /// The requested deepen depth (`--depth N`; [`INFINITE_DEPTH`] for
366    /// `--unshallow` and for the implicit deepen a shallow server runs on a
367    /// plain fetch; `0` for the deepen-since/deepen-not rev-list modes).
368    pub depth: u32,
369    /// The request carried `deepen-since` (trace2 `fetch-info` parity).
370    pub deepen_since: bool,
371    /// Number of `deepen-not` entries in the request (trace2 parity).
372    pub deepen_not: usize,
373    /// The client's existing shallow boundary (`$GIT_DIR/shallow`), replayed as
374    /// `shallow` lines in the upload-pack request.
375    pub client_shallow: Vec<ObjectId>,
376    /// The server's `shallow`/`unshallow` updates the client must fold into
377    /// `$GIT_DIR/shallow` after the pack lands (see [`crate::apply_shallow_info`]).
378    pub shallow_info: Vec<ProtocolV2FetchShallowInfo>,
379    /// Out-of-boundary commits (the parents of boundary commits that are not
380    /// themselves within the boundary): excluding these from the pack walk
381    /// truncates history at the boundary while keeping every tree/blob of the
382    /// boundary commits themselves.
383    pub excluded: HashSet<ObjectId>,
384    /// Parents of client-shallow commits this deepen un-shallowed, added as
385    /// extra pack tips so the newly visible history is sent (upload-pack adds
386    /// them to `want_obj` in `send_unshallow`).
387    pub extra_wants: Vec<ObjectId>,
388}
389
390/// Dereference `oid` through any chain of annotated tags to a commit, or `None`
391/// when it ultimately points at a tree or blob (`deref_tag` in upstream
392/// `shallow.c`'s boundary walk).
393fn peel_to_commit<R: ObjectReader>(
394    remote_db: &R,
395    format: ObjectFormat,
396    oid: &ObjectId,
397) -> Result<Option<ObjectId>> {
398    let mut oid = *oid;
399    loop {
400        let object = remote_db.read_object(&oid)?;
401        match object.object_type {
402            ObjectType::Commit => return Ok(Some(oid)),
403            ObjectType::Tag => oid = Tag::parse_ref(format, &object.body)?.object,
404            _ => return Ok(None),
405        }
406    }
407}
408
409/// Compute the deepen plan for a shallow local fetch, mirroring upstream
410/// `shallow.c::get_shallow_commits`: a breadth-first minimum-depth walk from the
411/// (tag-dereferenced) `heads` — the primary planned tips, upload-pack's
412/// `want_obj`, NOT auto-followed tags — where tips enter at depth 0 and a commit
413/// processed at depth `d` is a boundary commit when `d + 1 >= depth` (it is
414/// packed, but its parents are not walked).
415///
416/// `client_shallow` is the client's current boundary: boundary commits the
417/// client already has are not re-reported (`send_shallow` skips
418/// `CLIENT_SHALLOW`), and client-shallow commits now within the boundary are
419/// reported as `unshallow` with their parents returned as extra pack tips
420/// (`send_unshallow`).
421pub fn compute_local_deepen<R: ObjectReader>(
422    remote_db: &R,
423    format: ObjectFormat,
424    heads: &[ObjectId],
425    client_shallow: Vec<ObjectId>,
426    depth: u32,
427    deepen_relative: bool,
428) -> Result<LocalDeepenPlan> {
429    // `--deepen=N`: the boundary moves N commits past the client's current
430    // boundary (upstream `get_shallows_depth` + `depth +=`).
431    let depth = if deepen_relative && depth < INFINITE_DEPTH {
432        depth.saturating_add(client_shallow_min_depth(
433            remote_db,
434            format,
435            heads,
436            &client_shallow,
437        )?)
438    } else {
439        depth
440    };
441    let mut min_depth: HashMap<ObjectId, u32> = HashMap::new();
442    let mut queue: VecDeque<ObjectId> = VecDeque::new();
443    for head in heads {
444        let Some(commit) = peel_to_commit(remote_db, format, head)? else {
445            continue;
446        };
447        if let std::collections::hash_map::Entry::Vacant(entry) = min_depth.entry(commit) {
448            entry.insert(0);
449            queue.push_back(commit);
450        }
451    }
452    // FIFO processing with uniform edge weight makes the first visit the
453    // minimum depth, so each commit is processed exactly once and expands its
454    // parents only when it is within the boundary — the same fixpoint as
455    // upstream's decrease-key re-walks.
456    let mut boundary = Vec::new();
457    let mut boundary_parents = HashSet::new();
458    while let Some(oid) = queue.pop_front() {
459        let commit_depth = min_depth[&oid];
460        let object = remote_db.read_object(&oid)?;
461        let parents = sley_odb::grafted_parents(
462            remote_db,
463            &oid,
464            Commit::parse_ref(format, &object.body)?.parents,
465        );
466        // A commit is boundary when the requested depth cuts at it, or when
467        // the server's own history is cut at it (a shallow server reports its
468        // graft points to the client — upstream `get_shallows_or_depth`).
469        if (depth != INFINITE_DEPTH && commit_depth + 1 >= depth)
470            || remote_db.is_shallow_graft(&oid)
471        {
472            boundary.push(oid);
473            boundary_parents.extend(parents);
474            continue;
475        }
476        for parent in parents {
477            if let std::collections::hash_map::Entry::Vacant(entry) = min_depth.entry(parent) {
478                entry.insert(commit_depth + 1);
479                queue.push_back(parent);
480            }
481        }
482    }
483    // A boundary commit's parent can itself be within the boundary via a
484    // shorter path (and is then packed); only parents the walk never reached
485    // are excluded.
486    let excluded = boundary_parents
487        .into_iter()
488        .filter(|parent| !min_depth.contains_key(parent))
489        .collect::<HashSet<_>>();
490
491    let client: HashSet<ObjectId> = client_shallow.iter().copied().collect();
492    let boundary_set: HashSet<ObjectId> = boundary.iter().copied().collect();
493    let mut shallow_info = Vec::new();
494    for oid in &boundary {
495        if !client.contains(oid) {
496            shallow_info.push(ProtocolV2FetchShallowInfo::Shallow(*oid));
497        }
498    }
499    let mut extra_wants = Vec::new();
500    for oid in &client_shallow {
501        // A client-shallow commit is unshallowed when the walk reached it as
502        // a non-boundary commit (upstream `send_unshallow`: NOT_SHALLOW set).
503        let unshallowed = min_depth.contains_key(oid) && !boundary_set.contains(oid);
504        if !unshallowed {
505            continue;
506        }
507        shallow_info.push(ProtocolV2FetchShallowInfo::Unshallow(*oid));
508        let object = remote_db.read_object(oid)?;
509        extra_wants.extend(sley_odb::grafted_parents(
510            remote_db,
511            oid,
512            Commit::parse_ref(format, &object.body)?.parents,
513        ));
514    }
515    Ok(LocalDeepenPlan {
516        depth,
517        deepen_since: false,
518        deepen_not: 0,
519        client_shallow,
520        shallow_info,
521        excluded,
522        extra_wants,
523    })
524}
525
526/// Upstream `INFINITE_DEPTH`: `--unshallow`, and the implicit deepen a shallow
527/// server runs for a plain fetch so its graft points reach the client.
528pub const INFINITE_DEPTH: u32 = 0x7fff_ffff;
529
530/// Upstream `get_shallows_depth`: the minimum depth (head = 1) at which the
531/// walk from `heads` meets one of the client's shallow points, or 0 when it
532/// never does. Used to make `--deepen=N` relative to the current boundary.
533fn client_shallow_min_depth<R: ObjectReader>(
534    remote_db: &R,
535    format: ObjectFormat,
536    heads: &[ObjectId],
537    client_shallow: &[ObjectId],
538) -> Result<u32> {
539    if client_shallow.is_empty() {
540        return Ok(0);
541    }
542    let client: HashSet<ObjectId> = client_shallow.iter().copied().collect();
543    let mut min_depth: HashMap<ObjectId, u32> = HashMap::new();
544    let mut queue: VecDeque<ObjectId> = VecDeque::new();
545    for head in heads {
546        let Some(commit) = peel_to_commit(remote_db, format, head)? else {
547            continue;
548        };
549        if let std::collections::hash_map::Entry::Vacant(entry) = min_depth.entry(commit) {
550            entry.insert(1);
551            queue.push_back(commit);
552        }
553    }
554    let mut best: u32 = 0;
555    while let Some(oid) = queue.pop_front() {
556        let commit_depth = min_depth[&oid];
557        if client.contains(&oid) && (best == 0 || commit_depth < best) {
558            best = commit_depth;
559        }
560        let object = remote_db.read_object(&oid)?;
561        let parents = sley_odb::grafted_parents(
562            remote_db,
563            &oid,
564            Commit::parse_ref(format, &object.body)?.parents,
565        );
566        for parent in parents {
567            if let std::collections::hash_map::Entry::Vacant(entry) = min_depth.entry(parent) {
568                entry.insert(commit_depth + 1);
569                queue.push_back(parent);
570            }
571        }
572    }
573    Ok(best)
574}
575
576/// Deepen plan for the rev-list modes (`--shallow-since`, `--shallow-exclude`),
577/// mirroring upstream `get_shallow_commits_by_rev_list`: the kept set is every
578/// commit reachable from `heads` that is newer than `since` (when given) and
579/// not reachable from a `deepen_not` tip; the boundary is every kept commit
580/// with at least one parent outside the kept set.
581pub fn compute_local_deepen_by_rev_list<R: ObjectReader>(
582    remote_db: &R,
583    format: ObjectFormat,
584    heads: &[ObjectId],
585    client_shallow: Vec<ObjectId>,
586    since: Option<i64>,
587    deepen_not: &[ObjectId],
588) -> Result<LocalDeepenPlan> {
589    // Closure of the deepen-not tips (commits to subtract from the kept set).
590    let mut excluded_not: HashSet<ObjectId> = HashSet::new();
591    let mut queue: VecDeque<ObjectId> = VecDeque::new();
592    for tip in deepen_not {
593        if let Some(commit) = peel_to_commit(remote_db, format, tip)?
594            && excluded_not.insert(commit)
595        {
596            queue.push_back(commit);
597        }
598    }
599    while let Some(oid) = queue.pop_front() {
600        let object = remote_db.read_object(&oid)?;
601        for parent in sley_odb::grafted_parents(
602            remote_db,
603            &oid,
604            Commit::parse_ref(format, &object.body)?.parents,
605        ) {
606            if excluded_not.insert(parent) {
607                queue.push_back(parent);
608            }
609        }
610    }
611
612    let commit_time = |oid: &ObjectId| -> Result<i64> {
613        let object = remote_db.read_object(oid)?;
614        Ok(Commit::parse_ref(format, &object.body)?
615            .committer_signature()
616            .map(|signature| signature.time.seconds)
617            .unwrap_or(0))
618    };
619    let keeps = |oid: &ObjectId| -> Result<bool> {
620        if excluded_not.contains(oid) {
621            return Ok(false);
622        }
623        match since {
624            Some(since) => Ok(commit_time(oid)? >= since),
625            None => Ok(true),
626        }
627    };
628
629    // Kept-set walk: only kept commits are expanded, so the walk never reads
630    // objects past the cut (and stops at server graft points via the seam).
631    let mut kept: HashSet<ObjectId> = HashSet::new();
632    let mut kept_order: Vec<ObjectId> = Vec::new();
633    let mut queue: VecDeque<ObjectId> = VecDeque::new();
634    for head in heads {
635        let Some(commit) = peel_to_commit(remote_db, format, head)? else {
636            continue;
637        };
638        if keeps(&commit)? && kept.insert(commit) {
639            kept_order.push(commit);
640            queue.push_back(commit);
641        }
642    }
643    while let Some(oid) = queue.pop_front() {
644        let object = remote_db.read_object(&oid)?;
645        for parent in sley_odb::grafted_parents(
646            remote_db,
647            &oid,
648            Commit::parse_ref(format, &object.body)?.parents,
649        ) {
650            if !kept.contains(&parent) && keeps(&parent)? {
651                kept.insert(parent);
652                kept_order.push(parent);
653                queue.push_back(parent);
654            }
655        }
656    }
657    if kept.is_empty() {
658        // Upstream `get_shallow_commits_by_rev_list` dies here.
659        return Err(GitError::Command(
660            "no commits selected for shallow requests".into(),
661        ));
662    }
663
664    // Boundary: kept commits with a parent outside the kept set.
665    let mut boundary = Vec::new();
666    let mut boundary_set: HashSet<ObjectId> = HashSet::new();
667    let mut excluded: HashSet<ObjectId> = HashSet::new();
668    for oid in &kept_order {
669        let object = remote_db.read_object(oid)?;
670        let parents = sley_odb::grafted_parents(
671            remote_db,
672            oid,
673            Commit::parse_ref(format, &object.body)?.parents,
674        );
675        let mut is_boundary = false;
676        for parent in parents {
677            if !kept.contains(&parent) {
678                is_boundary = true;
679                excluded.insert(parent);
680            }
681        }
682        if is_boundary && boundary_set.insert(*oid) {
683            boundary.push(*oid);
684        }
685    }
686
687    let client: HashSet<ObjectId> = client_shallow.iter().copied().collect();
688    let mut shallow_info = Vec::new();
689    for oid in &boundary {
690        if !client.contains(oid) {
691            shallow_info.push(ProtocolV2FetchShallowInfo::Shallow(*oid));
692        }
693    }
694    let mut extra_wants = Vec::new();
695    for oid in &client_shallow {
696        let unshallowed = kept.contains(oid) && !boundary_set.contains(oid);
697        if !unshallowed {
698            continue;
699        }
700        shallow_info.push(ProtocolV2FetchShallowInfo::Unshallow(*oid));
701        let object = remote_db.read_object(oid)?;
702        extra_wants.extend(sley_odb::grafted_parents(
703            remote_db,
704            oid,
705            Commit::parse_ref(format, &object.body)?.parents,
706        ));
707    }
708    Ok(LocalDeepenPlan {
709        depth: 0,
710        deepen_since: since.is_some(),
711        deepen_not: deepen_not.len(),
712        client_shallow,
713        shallow_info,
714        excluded,
715        extra_wants,
716    })
717}
718
719/// Fetch `wants` from a local repository at `remote_git_dir` into the repository
720/// at `git_dir`, round-tripping the request and response through the protocol
721/// codecs into the in-process upload-pack so the local path exercises the same
722/// wire format as the networked transports. Objects already present locally are
723/// skipped; `promisor` selects promisor-pack installation.
724///
725/// When `deepen` carries a [`LocalDeepenPlan`] (computed by the caller from the
726/// primary planned tips via [`compute_local_deepen`]), the fetch is shallow: the
727/// request replays the client's boundary as `shallow` lines plus a `deepen`
728/// line, the pack walk stops at the plan's boundary, and the returned
729/// shallow-info updates must be folded into `$GIT_DIR/shallow` (see
730/// [`crate::apply_shallow_info`]). Empty for a full fetch.
731#[allow(clippy::too_many_arguments)]
732pub fn install_fetch_pack_via_local_upload_pack(
733    git_dir: &Path,
734    remote_git_dir: &Path,
735    format: ObjectFormat,
736    wants: Vec<ObjectId>,
737    deepen: Option<&LocalDeepenPlan>,
738    promisor: bool,
739    filter: Option<sley_odb::PackObjectFilter>,
740    unpack_limit: Option<usize>,
741) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
742    if wants.is_empty() {
743        return Ok(Vec::new());
744    }
745    let local_db = FileObjectDatabase::from_git_dir(git_dir, format);
746    // A deepen request must always run: even when every want is already present
747    // the shallow boundary may move (mirrors the SSH path).
748    if deepen.is_none()
749        && wants
750            .iter()
751            .map(|want| local_db.contains(want))
752            .collect::<Result<Vec<_>>>()?
753            .into_iter()
754            .all(|contains| contains)
755    {
756        return Ok(Vec::new());
757    }
758
759    let request = UploadPackRequest {
760        wants,
761        // The `shallow` capability accompanies a deepen request on the wire
762        // (mirrors the SSH path); a plain fetch keeps its existing wire form.
763        capabilities: deepen
764            .map(|_| {
765                vec![Capability {
766                    name: "shallow".into(),
767                    value: None,
768                }]
769            })
770            .unwrap_or_default(),
771        shallow: deepen
772            .map(|plan| plan.client_shallow.clone())
773            .unwrap_or_default(),
774        deepen: deepen.and_then(|plan| (plan.depth > 0).then_some(plan.depth)),
775        ..UploadPackRequest::default()
776    };
777    let mut encoded_request = Vec::new();
778    write_upload_pack_request(&mut encoded_request, Some(&request))?;
779    let decoded_request = read_upload_pack_request(format, &mut encoded_request.as_slice())?
780        .ok_or_else(|| GitError::InvalidFormat("encoded upload-pack request was empty".into()))?;
781
782    let haves = local_have_oids(git_dir, format)?;
783    let negotiation = UploadPackNegotiationRequest { haves, done: true };
784    let mut encoded_negotiation = Vec::new();
785    write_upload_pack_negotiation_request(&mut encoded_negotiation, &negotiation)?;
786    let decoded_negotiation =
787        read_upload_pack_negotiation_request(format, &mut encoded_negotiation.as_slice())?;
788
789    let remote_db = FileObjectDatabase::from_git_dir(remote_git_dir, format);
790    for want in &decoded_request.wants {
791        if !remote_db.contains(want)? {
792            return Err(GitError::InvalidObject(format!(
793                "upload-pack requested missing object {want}"
794            )));
795        }
796    }
797    let known_haves = decoded_negotiation
798        .haves
799        .into_iter()
800        .filter_map(|oid| match remote_db.contains(&oid) {
801            Ok(true) => Some(Ok(oid)),
802            Ok(false) => None,
803            Err(err) => Some(Err(err)),
804        })
805        .collect::<Result<Vec<_>>>()?;
806    // Trace2 `fetch-info` parity: upstream upload-pack emits a data_json
807    // event the shallow tests grep for; the in-process server inherits the
808    // client's GIT_TRACE2_EVENT just like a spawned upload-pack would.
809    trace2_fetch_info(
810        known_haves.len(),
811        decoded_request.wants.len(),
812        deepen.map(|plan| plan.depth).unwrap_or(0),
813        deepen.map(|plan| plan.client_shallow.len()).unwrap_or(0),
814        deepen.is_some_and(|plan| plan.deepen_since),
815        deepen.map(|plan| plan.deepen_not).unwrap_or(0),
816        filter,
817    );
818    // With a deepen plan the haves walk is cut at the client's existing
819    // boundary: having a commit inside the old shallow window must not imply
820    // having the history below it (upstream runs pack-objects with the
821    // client's shallow file for exactly this reason).
822    let mut excluded = match deepen {
823        Some(plan) => {
824            let cut: HashSet<ObjectId> = plan.client_shallow.iter().copied().collect();
825            sley_odb::collect_reachable_object_ids_with_cut(&remote_db, format, known_haves, &cut)?
826        }
827        None => collect_reachable_object_ids(&remote_db, format, known_haves)?,
828    };
829    let mut starts = decoded_request.wants;
830    if let Some(plan) = deepen {
831        // Stop the pack walk at the shallow boundary and pack the history a
832        // moved boundary newly exposes.
833        excluded.extend(plan.excluded.iter().copied());
834        starts.extend(plan.extra_wants.iter().copied());
835    }
836    build_and_install_reachable_pack_filtered(
837        &remote_db,
838        &local_db,
839        format,
840        starts,
841        &excluded,
842        RawPackInstallOptions { promisor },
843        filter,
844        unpack_limit,
845    )?;
846    Ok(deepen
847        .map(|plan| plan.shallow_info.clone())
848        .unwrap_or_default())
849}
850
851/// Append upstream upload-pack's `fetch-info` data_json event to the file
852/// named by `GIT_TRACE2_EVENT` (`trace2_fetch_info` in `upload-pack.c`). The
853/// subset of fields the test suite greps is emitted with upstream spellings.
854fn trace2_fetch_info(
855    haves: usize,
856    wants: usize,
857    depth: u32,
858    shallows: usize,
859    deepen_since: bool,
860    deepen_not: usize,
861    filter: Option<sley_odb::PackObjectFilter>,
862) {
863    let Some(path) = std::env::var_os("GIT_TRACE2_EVENT") else {
864        return;
865    };
866    if path.is_empty() {
867        return;
868    }
869    let filter_json = match filter {
870        Some(sley_odb::PackObjectFilter::BlobNone) => "\"blob:none\"".to_string(),
871        None => "null".to_string(),
872    };
873    let line = format!(
874        "{{\"event\":\"data_json\",\"thread\":\"main\",\"category\":\"upload-pack\",\"key\":\"fetch-info\",\"value\":{{\"haves\":{haves},\"wants\":{wants},\"want-refs\":0,\"depth\":{depth},\"shallows\":{shallows},\"deepen-since\":{deepen_since},\"deepen-not\":{deepen_not},\"deepen-relative\":false,\"filter\":{filter_json}}}}}\n"
875    );
876    if let Ok(mut file) = std::fs::OpenOptions::new()
877        .create(true)
878        .append(true)
879        .open(&path)
880    {
881        use std::io::Write as _;
882        let _ = file.write_all(line.as_bytes());
883    }
884}