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::{
16    Capability, GitError, ObjectFormat, ObjectId, Result, UPSTREAM_GIT_COMPAT_VERSION,
17};
18use sley_object::{Commit, ObjectType, Tag};
19use sley_odb::{
20    FileObjectDatabase, ObjectReader, RawPackInstallOptions, build_and_install_reachable_pack,
21    build_and_install_reachable_pack_filtered, build_reachable_pack, collect_reachable_object_ids,
22};
23use sley_protocol::{
24    PKT_LINE_MAX_PAYLOAD_LEN, ProtocolV2FetchAcknowledgment,
25    ProtocolV2FetchRequest, ProtocolV2FetchResponseSection, ProtocolV2FetchShallowInfo,
26    ProtocolV2LsRefsRecord, ProtocolV2LsRefsRef, ProtocolV2LsRefsRequest, ProtocolVersion,
27    ReceivePackFeatures, ReceivePackPushRequest, ReceivePackReportStatus, ReceivePackRequest,
28    RefAdvertisement, SideBandChannel, SideBandPacket, TransportHandshake, UploadPackFeatures,
29    UploadPackNegotiationRequest, UploadPackPackfileResponse, UploadPackRawPackfileResponse,
30    UploadPackRequest, apply_receive_pack_push_request, build_upload_pack_raw_packfile_response,
31    encode_receive_pack_features, encode_upload_pack_features,
32    read_protocol_v2_command_request, read_upload_pack_negotiation_request, read_upload_pack_request,
33    write_protocol_v2_advertisement, write_protocol_v2_fetch_response,
34    write_protocol_v2_ls_refs_response, write_upload_pack_negotiation_request,
35    write_upload_pack_request,
36};
37use sley_refs::{DeleteRef, FileRefStore, Ref, RefPrecondition, RefTarget};
38
39/// The all-zero object id for `format`, used for the synthetic
40/// `capabilities^{}` advertisement when a repository has no refs.
41fn zero_oid(format: ObjectFormat) -> Result<ObjectId> {
42    Ok(ObjectId::null(format))
43}
44
45/// Resolve a (possibly symbolic) ref target to its object id, following up to
46/// five levels of symbolic indirection, returning the first symbolic name seen.
47fn resolve_for_each_ref_target(
48    store: &FileRefStore,
49    reference: &Ref,
50) -> Result<Option<(ObjectId, Option<String>)>> {
51    let mut target = reference.target.clone();
52    let mut symref = None;
53    for _ in 0..5 {
54        match target {
55            RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
56            RefTarget::Symbolic(name) => {
57                symref.get_or_insert_with(|| name.clone());
58                let Some(next) = store.read_ref(&name)? else {
59                    return Ok(None);
60                };
61                target = next;
62            }
63        }
64    }
65    Ok(None)
66}
67
68/// The upload-pack capabilities advertised for the repository at `git_dir`:
69/// the object format, side-band-64k, and a `HEAD` symref hint if present.
70pub fn upload_pack_features(git_dir: &Path, format: ObjectFormat) -> Result<UploadPackFeatures> {
71    let store = FileRefStore::new(git_dir, format);
72    let mut symrefs = Vec::new();
73    if let Some(RefTarget::Symbolic(target)) = store.read_ref("HEAD")? {
74        symrefs.push(format!("HEAD:{target}"));
75    }
76    Ok(UploadPackFeatures {
77        object_format: Some(format),
78        side_band_64k: true,
79        symrefs,
80        ..UploadPackFeatures::default()
81    })
82}
83
84/// Whether the client negotiated a side-band channel for the packfile response.
85pub fn upload_pack_request_uses_sideband(request: &UploadPackRequest) -> bool {
86    request
87        .capabilities
88        .iter()
89        .any(|capability| matches!(capability.name.as_str(), "side-band" | "side-band-64k"))
90}
91
92/// Re-frame a raw packfile response as side-band data packets, chunked to the
93/// pkt-line payload limit (less the one-byte channel prefix).
94pub fn upload_pack_sideband_response(
95    response: UploadPackRawPackfileResponse,
96) -> UploadPackPackfileResponse {
97    let mut sideband = Vec::new();
98    let chunk_len = PKT_LINE_MAX_PAYLOAD_LEN - 1;
99    for chunk in response.packfile.chunks(chunk_len) {
100        sideband.push(SideBandPacket {
101            channel: SideBandChannel::Data,
102            data: chunk.to_vec(),
103        });
104    }
105    UploadPackPackfileResponse {
106        acknowledgments: response.acknowledgments,
107        sideband,
108    }
109}
110
111/// Encode `features` into the leading ref advertisement's capability list,
112/// inserting a synthetic `capabilities^{}` entry when there are no refs.
113pub fn attach_upload_pack_capabilities(
114    advertisements: &mut Vec<RefAdvertisement>,
115    format: ObjectFormat,
116    features: &UploadPackFeatures,
117) -> Result<()> {
118    let capabilities = encode_upload_pack_features(features)?;
119    if let Some(first) = advertisements.first_mut() {
120        first.capabilities = capabilities;
121    } else {
122        advertisements.push(RefAdvertisement {
123            oid: zero_oid(format)?,
124            name: "capabilities^{}".into(),
125            capabilities,
126        });
127    }
128    Ok(())
129}
130
131/// Serve an upload-pack request from the repository at `git_dir`: build the
132/// packfile that carries every reachable object the client `wants` but does not
133/// already `haves`, framed as a raw (non-side-band) response.
134pub fn upload_pack_from_local_repository(
135    git_dir: &Path,
136    format: ObjectFormat,
137    features: &UploadPackFeatures,
138    request: UploadPackRequest,
139    haves: HashSet<ObjectId>,
140) -> Result<UploadPackRawPackfileResponse> {
141    let db = FileObjectDatabase::from_git_dir(git_dir, format);
142    build_upload_pack_raw_packfile_response(
143        features,
144        request,
145        haves,
146        |oid| db.contains(oid),
147        |wants, known_haves| {
148            let excluded = collect_reachable_object_ids(&db, format, known_haves)?;
149            build_reachable_pack(&db, format, wants, &excluded)
150                .map(|pack| pack.map(|pack| pack.pack))
151        },
152    )
153}
154
155/// The receive-pack capabilities advertised for a local repository: report
156/// status, ref deletion, ofs-delta, push-options, quiet, and the object format.
157pub fn receive_pack_features(format: ObjectFormat) -> ReceivePackFeatures {
158    ReceivePackFeatures {
159        report_status: true,
160        delete_refs: true,
161        ofs_delta: true,
162        push_options: true,
163        quiet: true,
164        object_format: Some(format),
165        ..ReceivePackFeatures::default()
166    }
167}
168
169/// Whether the client negotiated `push-options` (so the caller must read the
170/// push-option section that follows the command list).
171pub fn receive_pack_request_uses_push_options(request: &ReceivePackRequest) -> bool {
172    request
173        .capabilities
174        .iter()
175        .any(|capability| capability.name == "push-options")
176}
177
178/// Encode `features` into the leading ref advertisement's capability list,
179/// inserting a synthetic `capabilities^{}` entry when there are no refs.
180pub fn attach_receive_pack_capabilities(
181    advertisements: &mut Vec<RefAdvertisement>,
182    format: ObjectFormat,
183    features: &ReceivePackFeatures,
184) -> Result<()> {
185    let capabilities = encode_receive_pack_features(features)?;
186    if let Some(first) = advertisements.first_mut() {
187        first.capabilities = capabilities;
188    } else {
189        advertisements.push(RefAdvertisement {
190            oid: zero_oid(format)?,
191            name: "capabilities^{}".into(),
192            capabilities,
193        });
194    }
195    Ok(())
196}
197
198/// Apply a receive-pack push to the repository at `remote_git_dir`: install the
199/// incoming packfile and execute the ref creations/updates/deletions, returning
200/// the report-status describing what happened.
201pub fn receive_pack_into_local_repository(
202    remote_git_dir: &Path,
203    format: ObjectFormat,
204    request: &ReceivePackPushRequest,
205) -> Result<ReceivePackReportStatus> {
206    let remote_store = FileRefStore::new(remote_git_dir, format);
207    let remote_db = FileObjectDatabase::from_git_dir(remote_git_dir, format);
208    apply_receive_pack_push_request(
209        &receive_pack_features(format),
210        request,
211        |name| match remote_store.read_ref(name)? {
212            Some(RefTarget::Direct(oid)) => Ok(Some(oid)),
213            Some(RefTarget::Symbolic(_)) | None => Ok(None),
214        },
215        |packfile| remote_db.install_raw_pack(packfile).map(|_| ()),
216        |oid| remote_db.contains(oid),
217        |commands| {
218            let mut tx = remote_store.transaction();
219            for command in commands {
220                let precondition = if command.old_id.is_null() {
221                    RefPrecondition::MustNotExist
222                } else {
223                    RefPrecondition::MustExistAndMatch(RefTarget::Direct(command.old_id))
224                };
225                tx.update_to(
226                    command.name.clone(),
227                    RefTarget::Direct(command.new_id),
228                    precondition,
229                    None,
230                );
231            }
232            tx.commit()
233        },
234        |command| {
235            remote_store
236                .delete_ref_checked(DeleteRef {
237                    name: command.name.clone(),
238                    expected_old: (!command.old_id.is_null()).then_some(command.old_id),
239                    reflog: None,
240                })
241                .map(|_| ())
242                .map_err(|err| GitError::Transaction(err.to_string()))
243        },
244    )
245}
246
247/// Apply a local receive-pack request whose pack can be built from `source_db`
248/// after receive-pack preflight checks pass.
249///
250/// This keeps local push on the same validation path as raw receive-pack while
251/// avoiding a raw-pack round trip: the install closure builds the reachable
252/// pack and installs the generated pack/index directly.
253pub fn receive_pack_reachable_pack_into_local_repository(
254    remote_git_dir: &Path,
255    format: ObjectFormat,
256    request: &ReceivePackPushRequest,
257    source_db: &FileObjectDatabase,
258    starts: Vec<ObjectId>,
259    excluded: HashSet<ObjectId>,
260) -> Result<ReceivePackReportStatus> {
261    let remote_store = FileRefStore::new(remote_git_dir, format);
262    let remote_db = FileObjectDatabase::from_git_dir(remote_git_dir, format);
263    let mut starts = Some(starts);
264    apply_receive_pack_push_request(
265        &receive_pack_features(format),
266        request,
267        |name| match remote_store.read_ref(name)? {
268            Some(RefTarget::Direct(oid)) => Ok(Some(oid)),
269            Some(RefTarget::Symbolic(_)) | None => Ok(None),
270        },
271        |_| {
272            let starts = starts.take().ok_or_else(|| {
273                GitError::InvalidFormat("receive-pack attempted to install pack twice".into())
274            })?;
275            build_and_install_reachable_pack(
276                source_db,
277                &remote_db,
278                format,
279                starts,
280                &excluded,
281                RawPackInstallOptions { promisor: false },
282            )?;
283            Ok(())
284        },
285        |oid| remote_db.contains(oid),
286        |commands| {
287            let mut tx = remote_store.transaction();
288            for command in commands {
289                let precondition = if command.old_id.is_null() {
290                    RefPrecondition::MustNotExist
291                } else {
292                    RefPrecondition::MustExistAndMatch(RefTarget::Direct(command.old_id))
293                };
294                tx.update_to(
295                    command.name.clone(),
296                    RefTarget::Direct(command.new_id),
297                    precondition,
298                    None,
299                );
300            }
301            tx.commit()
302        },
303        |command| {
304            remote_store
305                .delete_ref_checked(DeleteRef {
306                    name: command.name.clone(),
307                    expected_old: (!command.old_id.is_null()).then_some(command.old_id),
308                    reflog: None,
309                })
310                .map(|_| ())
311                .map_err(|err| GitError::Transaction(err.to_string()))
312        },
313    )
314}
315
316/// The ref advertisements a local repository would send to a fetching client:
317/// `HEAD` (if resolvable) followed by every ref, each resolved to its object id.
318pub fn local_fetch_advertisements(
319    git_dir: &Path,
320    format: ObjectFormat,
321) -> Result<Vec<RefAdvertisement>> {
322    let store = FileRefStore::new(git_dir, format);
323    let mut advertisements = Vec::new();
324    if let Some(target) = store.read_ref("HEAD")? {
325        let reference = Ref {
326            name: "HEAD".to_string(),
327            target,
328        };
329        if let Some((oid, _)) = resolve_for_each_ref_target(&store, &reference)? {
330            advertisements.push(RefAdvertisement {
331                oid,
332                name: reference.name,
333                capabilities: Vec::new(),
334            });
335        }
336    }
337    for reference in store.list_refs()? {
338        let Some((oid, _)) = resolve_for_each_ref_target(&store, &reference)? else {
339            continue;
340        };
341        advertisements.push(RefAdvertisement {
342            oid,
343            name: reference.name,
344            capabilities: Vec::new(),
345        });
346    }
347    Ok(advertisements)
348}
349
350/// The object ids the local repository can offer as `have`s during negotiation:
351/// the deduplicated tips of its own advertisements.
352pub fn local_have_oids(git_dir: &Path, format: ObjectFormat) -> Result<Vec<ObjectId>> {
353    let mut seen = HashSet::new();
354    let mut haves = Vec::new();
355    for advertisement in local_fetch_advertisements(git_dir, format)? {
356        if seen.insert(advertisement.oid) {
357            haves.push(advertisement.oid);
358        }
359    }
360    Ok(haves)
361}
362
363/// The in-process upload-pack's plan for a `deepen` (shallow) local fetch:
364/// which `shallow`/`unshallow` updates to report, which commits the pack walk
365/// must stop at, and which extra tips become packable because the client's
366/// boundary moved.
367///
368/// Mirrors upstream `upload-pack.c::deepen` + `shallow.c::get_shallow_commits`.
369#[derive(Debug, Clone)]
370pub struct LocalDeepenPlan {
371    /// The requested deepen depth (`--depth N`; [`INFINITE_DEPTH`] for
372    /// `--unshallow` and for the implicit deepen a shallow server runs on a
373    /// plain fetch; `0` for the deepen-since/deepen-not rev-list modes).
374    pub depth: u32,
375    /// The request carried `deepen-since` (trace2 `fetch-info` parity).
376    pub deepen_since: bool,
377    /// Number of `deepen-not` entries in the request (trace2 parity).
378    pub deepen_not: usize,
379    /// The client's existing shallow boundary (`$GIT_DIR/shallow`), replayed as
380    /// `shallow` lines in the upload-pack request.
381    pub client_shallow: Vec<ObjectId>,
382    /// The server's `shallow`/`unshallow` updates the client must fold into
383    /// `$GIT_DIR/shallow` after the pack lands (see [`crate::apply_shallow_info`]).
384    pub shallow_info: Vec<ProtocolV2FetchShallowInfo>,
385    /// Out-of-boundary commits (the parents of boundary commits that are not
386    /// themselves within the boundary): excluding these from the pack walk
387    /// truncates history at the boundary while keeping every tree/blob of the
388    /// boundary commits themselves.
389    pub excluded: HashSet<ObjectId>,
390    /// Parents of client-shallow commits this deepen un-shallowed, added as
391    /// extra pack tips so the newly visible history is sent (upload-pack adds
392    /// them to `want_obj` in `send_unshallow`).
393    pub extra_wants: Vec<ObjectId>,
394}
395
396/// Dereference `oid` through any chain of annotated tags to a commit, or `None`
397/// when it ultimately points at a tree or blob (`deref_tag` in upstream
398/// `shallow.c`'s boundary walk).
399fn peel_to_commit<R: ObjectReader>(
400    remote_db: &R,
401    format: ObjectFormat,
402    oid: &ObjectId,
403) -> Result<Option<ObjectId>> {
404    let mut oid = *oid;
405    loop {
406        let object = remote_db.read_object(&oid)?;
407        match object.object_type {
408            ObjectType::Commit => return Ok(Some(oid)),
409            ObjectType::Tag => oid = Tag::parse_ref(format, &object.body)?.object,
410            _ => return Ok(None),
411        }
412    }
413}
414
415/// Compute the deepen plan for a shallow local fetch, mirroring upstream
416/// `shallow.c::get_shallow_commits`: a breadth-first minimum-depth walk from the
417/// (tag-dereferenced) `heads` — the primary planned tips, upload-pack's
418/// `want_obj`, NOT auto-followed tags — where tips enter at depth 0 and a commit
419/// processed at depth `d` is a boundary commit when `d + 1 >= depth` (it is
420/// packed, but its parents are not walked).
421///
422/// `client_shallow` is the client's current boundary: boundary commits the
423/// client already has are not re-reported (`send_shallow` skips
424/// `CLIENT_SHALLOW`), and client-shallow commits now within the boundary are
425/// reported as `unshallow` with their parents returned as extra pack tips
426/// (`send_unshallow`).
427pub fn compute_local_deepen<R: ObjectReader>(
428    remote_db: &R,
429    format: ObjectFormat,
430    heads: &[ObjectId],
431    client_shallow: Vec<ObjectId>,
432    depth: u32,
433    deepen_relative: bool,
434) -> Result<LocalDeepenPlan> {
435    // `--deepen=N`: the boundary moves N commits past the client's current
436    // boundary (upstream `get_shallows_depth` + `depth +=`).
437    let depth = if deepen_relative && depth < INFINITE_DEPTH {
438        depth.saturating_add(client_shallow_min_depth(
439            remote_db,
440            format,
441            heads,
442            &client_shallow,
443        )?)
444    } else {
445        depth
446    };
447    let mut min_depth: HashMap<ObjectId, u32> = HashMap::new();
448    let mut queue: VecDeque<ObjectId> = VecDeque::new();
449    for head in heads {
450        let Some(commit) = peel_to_commit(remote_db, format, head)? else {
451            continue;
452        };
453        if let std::collections::hash_map::Entry::Vacant(entry) = min_depth.entry(commit) {
454            entry.insert(0);
455            queue.push_back(commit);
456        }
457    }
458    // FIFO processing with uniform edge weight makes the first visit the
459    // minimum depth, so each commit is processed exactly once and expands its
460    // parents only when it is within the boundary — the same fixpoint as
461    // upstream's decrease-key re-walks.
462    let mut boundary = Vec::new();
463    let mut boundary_parents = HashSet::new();
464    while let Some(oid) = queue.pop_front() {
465        let commit_depth = min_depth[&oid];
466        let object = remote_db.read_object(&oid)?;
467        let parents = sley_odb::grafted_parents(
468            remote_db,
469            &oid,
470            Commit::parse_ref(format, &object.body)?.parents,
471        );
472        // A commit is boundary when the requested depth cuts at it, or when
473        // the server's own history is cut at it (a shallow server reports its
474        // graft points to the client — upstream `get_shallows_or_depth`).
475        if (depth != INFINITE_DEPTH && commit_depth + 1 >= depth)
476            || remote_db.is_shallow_graft(&oid)
477        {
478            boundary.push(oid);
479            boundary_parents.extend(parents);
480            continue;
481        }
482        for parent in parents {
483            if let std::collections::hash_map::Entry::Vacant(entry) = min_depth.entry(parent) {
484                entry.insert(commit_depth + 1);
485                queue.push_back(parent);
486            }
487        }
488    }
489    // A boundary commit's parent can itself be within the boundary via a
490    // shorter path (and is then packed); only parents the walk never reached
491    // are excluded.
492    let excluded = boundary_parents
493        .into_iter()
494        .filter(|parent| !min_depth.contains_key(parent))
495        .collect::<HashSet<_>>();
496
497    let client: HashSet<ObjectId> = client_shallow.iter().copied().collect();
498    let boundary_set: HashSet<ObjectId> = boundary.iter().copied().collect();
499    let mut shallow_info = Vec::new();
500    for oid in &boundary {
501        if !client.contains(oid) {
502            shallow_info.push(ProtocolV2FetchShallowInfo::Shallow(*oid));
503        }
504    }
505    let mut extra_wants = Vec::new();
506    for oid in &client_shallow {
507        // A client-shallow commit is unshallowed when the walk reached it as
508        // a non-boundary commit (upstream `send_unshallow`: NOT_SHALLOW set).
509        let unshallowed = min_depth.contains_key(oid) && !boundary_set.contains(oid);
510        if !unshallowed {
511            continue;
512        }
513        shallow_info.push(ProtocolV2FetchShallowInfo::Unshallow(*oid));
514        let object = remote_db.read_object(oid)?;
515        extra_wants.extend(sley_odb::grafted_parents(
516            remote_db,
517            oid,
518            Commit::parse_ref(format, &object.body)?.parents,
519        ));
520    }
521    Ok(LocalDeepenPlan {
522        depth,
523        deepen_since: false,
524        deepen_not: 0,
525        client_shallow,
526        shallow_info,
527        excluded,
528        extra_wants,
529    })
530}
531
532/// Upstream `INFINITE_DEPTH`: `--unshallow`, and the implicit deepen a shallow
533/// server runs for a plain fetch so its graft points reach the client.
534pub const INFINITE_DEPTH: u32 = 0x7fff_ffff;
535
536/// Upstream `get_shallows_depth`: the minimum depth (head = 1) at which the
537/// walk from `heads` meets one of the client's shallow points, or 0 when it
538/// never does. Used to make `--deepen=N` relative to the current boundary.
539fn client_shallow_min_depth<R: ObjectReader>(
540    remote_db: &R,
541    format: ObjectFormat,
542    heads: &[ObjectId],
543    client_shallow: &[ObjectId],
544) -> Result<u32> {
545    if client_shallow.is_empty() {
546        return Ok(0);
547    }
548    let client: HashSet<ObjectId> = client_shallow.iter().copied().collect();
549    let mut min_depth: HashMap<ObjectId, u32> = HashMap::new();
550    let mut queue: VecDeque<ObjectId> = VecDeque::new();
551    for head in heads {
552        let Some(commit) = peel_to_commit(remote_db, format, head)? else {
553            continue;
554        };
555        if let std::collections::hash_map::Entry::Vacant(entry) = min_depth.entry(commit) {
556            entry.insert(1);
557            queue.push_back(commit);
558        }
559    }
560    let mut best: u32 = 0;
561    while let Some(oid) = queue.pop_front() {
562        let commit_depth = min_depth[&oid];
563        if client.contains(&oid) && (best == 0 || commit_depth < best) {
564            best = commit_depth;
565        }
566        let object = remote_db.read_object(&oid)?;
567        let parents = sley_odb::grafted_parents(
568            remote_db,
569            &oid,
570            Commit::parse_ref(format, &object.body)?.parents,
571        );
572        for parent in parents {
573            if let std::collections::hash_map::Entry::Vacant(entry) = min_depth.entry(parent) {
574                entry.insert(commit_depth + 1);
575                queue.push_back(parent);
576            }
577        }
578    }
579    Ok(best)
580}
581
582/// Deepen plan for the rev-list modes (`--shallow-since`, `--shallow-exclude`),
583/// mirroring upstream `get_shallow_commits_by_rev_list`: the kept set is every
584/// commit reachable from `heads` that is newer than `since` (when given) and
585/// not reachable from a `deepen_not` tip; the boundary is every kept commit
586/// with at least one parent outside the kept set.
587pub fn compute_local_deepen_by_rev_list<R: ObjectReader>(
588    remote_db: &R,
589    format: ObjectFormat,
590    heads: &[ObjectId],
591    client_shallow: Vec<ObjectId>,
592    since: Option<i64>,
593    deepen_not: &[ObjectId],
594) -> Result<LocalDeepenPlan> {
595    // Closure of the deepen-not tips (commits to subtract from the kept set).
596    let mut excluded_not: HashSet<ObjectId> = HashSet::new();
597    let mut queue: VecDeque<ObjectId> = VecDeque::new();
598    for tip in deepen_not {
599        if let Some(commit) = peel_to_commit(remote_db, format, tip)?
600            && excluded_not.insert(commit)
601        {
602            queue.push_back(commit);
603        }
604    }
605    while let Some(oid) = queue.pop_front() {
606        let object = remote_db.read_object(&oid)?;
607        for parent in sley_odb::grafted_parents(
608            remote_db,
609            &oid,
610            Commit::parse_ref(format, &object.body)?.parents,
611        ) {
612            if excluded_not.insert(parent) {
613                queue.push_back(parent);
614            }
615        }
616    }
617
618    let commit_time = |oid: &ObjectId| -> Result<i64> {
619        let object = remote_db.read_object(oid)?;
620        Ok(Commit::parse_ref(format, &object.body)?
621            .committer_signature()
622            .map(|signature| signature.time.seconds)
623            .unwrap_or(0))
624    };
625    let keeps = |oid: &ObjectId| -> Result<bool> {
626        if excluded_not.contains(oid) {
627            return Ok(false);
628        }
629        match since {
630            Some(since) => Ok(commit_time(oid)? >= since),
631            None => Ok(true),
632        }
633    };
634
635    // Kept-set walk: only kept commits are expanded, so the walk never reads
636    // objects past the cut (and stops at server graft points via the seam).
637    let mut kept: HashSet<ObjectId> = HashSet::new();
638    let mut kept_order: Vec<ObjectId> = Vec::new();
639    let mut queue: VecDeque<ObjectId> = VecDeque::new();
640    for head in heads {
641        let Some(commit) = peel_to_commit(remote_db, format, head)? else {
642            continue;
643        };
644        if keeps(&commit)? && kept.insert(commit) {
645            kept_order.push(commit);
646            queue.push_back(commit);
647        }
648    }
649    while let Some(oid) = queue.pop_front() {
650        let object = remote_db.read_object(&oid)?;
651        for parent in sley_odb::grafted_parents(
652            remote_db,
653            &oid,
654            Commit::parse_ref(format, &object.body)?.parents,
655        ) {
656            if !kept.contains(&parent) && keeps(&parent)? {
657                kept.insert(parent);
658                kept_order.push(parent);
659                queue.push_back(parent);
660            }
661        }
662    }
663    if kept.is_empty() {
664        // Upstream `get_shallow_commits_by_rev_list` dies here.
665        return Err(GitError::Command(
666            "no commits selected for shallow requests".into(),
667        ));
668    }
669
670    // Boundary: kept commits with a parent outside the kept set.
671    let mut boundary = Vec::new();
672    let mut boundary_set: HashSet<ObjectId> = HashSet::new();
673    let mut excluded: HashSet<ObjectId> = HashSet::new();
674    for oid in &kept_order {
675        let object = remote_db.read_object(oid)?;
676        let parents = sley_odb::grafted_parents(
677            remote_db,
678            oid,
679            Commit::parse_ref(format, &object.body)?.parents,
680        );
681        let mut is_boundary = false;
682        for parent in parents {
683            if !kept.contains(&parent) {
684                is_boundary = true;
685                excluded.insert(parent);
686            }
687        }
688        if is_boundary && boundary_set.insert(*oid) {
689            boundary.push(*oid);
690        }
691    }
692
693    let client: HashSet<ObjectId> = client_shallow.iter().copied().collect();
694    let mut shallow_info = Vec::new();
695    for oid in &boundary {
696        if !client.contains(oid) {
697            shallow_info.push(ProtocolV2FetchShallowInfo::Shallow(*oid));
698        }
699    }
700    let mut extra_wants = Vec::new();
701    for oid in &client_shallow {
702        let unshallowed = kept.contains(oid) && !boundary_set.contains(oid);
703        if !unshallowed {
704            continue;
705        }
706        shallow_info.push(ProtocolV2FetchShallowInfo::Unshallow(*oid));
707        let object = remote_db.read_object(oid)?;
708        extra_wants.extend(sley_odb::grafted_parents(
709            remote_db,
710            oid,
711            Commit::parse_ref(format, &object.body)?.parents,
712        ));
713    }
714    Ok(LocalDeepenPlan {
715        depth: 0,
716        deepen_since: since.is_some(),
717        deepen_not: deepen_not.len(),
718        client_shallow,
719        shallow_info,
720        excluded,
721        extra_wants,
722    })
723}
724
725/// Fetch `wants` from a local repository at `remote_git_dir` into the repository
726/// at `git_dir`, round-tripping the request and response through the protocol
727/// codecs into the in-process upload-pack so the local path exercises the same
728/// wire format as the networked transports. Objects already present locally are
729/// skipped; `promisor` selects promisor-pack installation.
730///
731/// When `deepen` carries a [`LocalDeepenPlan`] (computed by the caller from the
732/// primary planned tips via [`compute_local_deepen`]), the fetch is shallow: the
733/// request replays the client's boundary as `shallow` lines plus a `deepen`
734/// line, the pack walk stops at the plan's boundary, and the returned
735/// shallow-info updates must be folded into `$GIT_DIR/shallow` (see
736/// [`crate::apply_shallow_info`]). Empty for a full fetch.
737#[allow(clippy::too_many_arguments)]
738pub fn install_fetch_pack_via_local_upload_pack(
739    git_dir: &Path,
740    remote_git_dir: &Path,
741    format: ObjectFormat,
742    wants: Vec<ObjectId>,
743    deepen: Option<&LocalDeepenPlan>,
744    promisor: bool,
745    filter: Option<sley_odb::PackObjectFilter>,
746    unpack_limit: Option<usize>,
747) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
748    if wants.is_empty() {
749        return Ok(Vec::new());
750    }
751    let local_db = FileObjectDatabase::from_git_dir(git_dir, format);
752    // A deepen request must always run: even when every want is already present
753    // the shallow boundary may move (mirrors the SSH path).
754    if deepen.is_none()
755        && wants
756            .iter()
757            .map(|want| local_db.contains(want))
758            .collect::<Result<Vec<_>>>()?
759            .into_iter()
760            .all(|contains| contains)
761    {
762        return Ok(Vec::new());
763    }
764
765    let request = UploadPackRequest {
766        wants,
767        // The `shallow` capability accompanies a deepen request on the wire
768        // (mirrors the SSH path); a plain fetch keeps its existing wire form.
769        capabilities: deepen
770            .map(|_| {
771                vec![Capability {
772                    name: "shallow".into(),
773                    value: None,
774                }]
775            })
776            .unwrap_or_default(),
777        shallow: deepen
778            .map(|plan| plan.client_shallow.clone())
779            .unwrap_or_default(),
780        deepen: deepen.and_then(|plan| (plan.depth > 0).then_some(plan.depth)),
781        ..UploadPackRequest::default()
782    };
783    let mut encoded_request = Vec::new();
784    write_upload_pack_request(&mut encoded_request, Some(&request))?;
785    let decoded_request = read_upload_pack_request(format, &mut encoded_request.as_slice())?
786        .ok_or_else(|| GitError::InvalidFormat("encoded upload-pack request was empty".into()))?;
787
788    let haves = local_have_oids(git_dir, format)?;
789    let negotiation = UploadPackNegotiationRequest { haves, done: true };
790    let mut encoded_negotiation = Vec::new();
791    write_upload_pack_negotiation_request(&mut encoded_negotiation, &negotiation)?;
792    let decoded_negotiation =
793        read_upload_pack_negotiation_request(format, &mut encoded_negotiation.as_slice())?;
794
795    let remote_db = FileObjectDatabase::from_git_dir(remote_git_dir, format);
796    for want in &decoded_request.wants {
797        if !remote_db.contains(want)? {
798            return Err(GitError::InvalidObject(format!(
799                "upload-pack requested missing object {want}"
800            )));
801        }
802    }
803    let known_haves = decoded_negotiation
804        .haves
805        .into_iter()
806        .filter_map(|oid| match remote_db.contains(&oid) {
807            Ok(true) => Some(Ok(oid)),
808            Ok(false) => None,
809            Err(err) => Some(Err(err)),
810        })
811        .collect::<Result<Vec<_>>>()?;
812    // Trace2 `fetch-info` parity: upstream upload-pack emits a data_json
813    // event the shallow tests grep for; the in-process server inherits the
814    // client's GIT_TRACE2_EVENT just like a spawned upload-pack would.
815    trace2_fetch_info(
816        known_haves.len(),
817        decoded_request.wants.len(),
818        deepen.map(|plan| plan.depth).unwrap_or(0),
819        deepen.map(|plan| plan.client_shallow.len()).unwrap_or(0),
820        deepen.is_some_and(|plan| plan.deepen_since),
821        deepen.map(|plan| plan.deepen_not).unwrap_or(0),
822        filter,
823    );
824    // With a deepen plan the haves walk is cut at the client's existing
825    // boundary: having a commit inside the old shallow window must not imply
826    // having the history below it (upstream runs pack-objects with the
827    // client's shallow file for exactly this reason).
828    let mut excluded = match deepen {
829        Some(plan) => {
830            let cut: HashSet<ObjectId> = plan.client_shallow.iter().copied().collect();
831            sley_odb::collect_reachable_object_ids_with_cut(&remote_db, format, known_haves, &cut)?
832        }
833        None => collect_reachable_object_ids(&remote_db, format, known_haves)?,
834    };
835    let mut starts = decoded_request.wants;
836    if let Some(plan) = deepen {
837        // Stop the pack walk at the shallow boundary and pack the history a
838        // moved boundary newly exposes.
839        excluded.extend(plan.excluded.iter().copied());
840        starts.extend(plan.extra_wants.iter().copied());
841    }
842    build_and_install_reachable_pack_filtered(
843        &remote_db,
844        &local_db,
845        format,
846        starts,
847        &excluded,
848        RawPackInstallOptions { promisor },
849        filter,
850        unpack_limit,
851    )?;
852    Ok(deepen
853        .map(|plan| plan.shallow_info.clone())
854        .unwrap_or_default())
855}
856
857/// Append upstream upload-pack's `fetch-info` data_json event to the file
858/// named by `GIT_TRACE2_EVENT` (`trace2_fetch_info` in `upload-pack.c`). The
859/// subset of fields the test suite greps is emitted with upstream spellings.
860fn trace2_fetch_info(
861    haves: usize,
862    wants: usize,
863    depth: u32,
864    shallows: usize,
865    deepen_since: bool,
866    deepen_not: usize,
867    filter: Option<sley_odb::PackObjectFilter>,
868) {
869    let Some(path) = std::env::var_os("GIT_TRACE2_EVENT") else {
870        return;
871    };
872    if path.is_empty() {
873        return;
874    }
875    let filter_json = match filter {
876        Some(sley_odb::PackObjectFilter::BlobNone) => "\"blob:none\"".to_string(),
877        None => "null".to_string(),
878    };
879    let line = format!(
880        "{{\"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"
881    );
882    if let Ok(mut file) = std::fs::OpenOptions::new()
883        .create(true)
884        .append(true)
885        .open(&path)
886    {
887        use std::io::Write as _;
888        let _ = file.write_all(line.as_bytes());
889    }
890}
891
892// ---------------------------------------------------------------------------
893// Protocol v2 upload-pack server (`GIT_PROTOCOL=version=2`).
894//
895// Mirrors upstream `upload-pack.c::upload_pack_v2` / `serve.c`: advertise the
896// v2 capabilities, then read `command=ls-refs` / `command=fetch` requests until
897// EOF, answering each with the protocol-v2 response. The transport (file://
898// spawned process, git:// daemon child) hands us a connected stdin/stdout pair;
899// everything below is transport-independent.
900// ---------------------------------------------------------------------------
901
902/// The v2 capabilities advertised by the upload-pack server, in the order git
903/// emits them: `agent`, `ls-refs=unborn`, `fetch=shallow wait-for-done`,
904/// `server-option`, `object-format=<hash>`.
905fn upload_pack_v2_capabilities(format: ObjectFormat) -> Vec<Capability> {
906    vec![
907        Capability {
908            name: "agent".into(),
909            value: Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}")),
910        },
911        Capability {
912            name: "ls-refs".into(),
913            value: Some("unborn".into()),
914        },
915        Capability {
916            name: "fetch".into(),
917            value: Some("shallow wait-for-done".into()),
918        },
919        Capability {
920            name: "server-option".into(),
921            value: None,
922        },
923        Capability {
924            name: "object-format".into(),
925            value: Some(format.name().into()),
926        },
927    ]
928}
929
930/// Resolve the symref target of `HEAD` (e.g. `refs/heads/main`) for the
931/// `symrefs`/symref-target ls-refs attribute, following one level of symbolic
932/// indirection. Returns `None` for a detached or missing `HEAD`.
933fn head_symref_target(store: &FileRefStore) -> Result<Option<String>> {
934    match store.read_ref("HEAD")? {
935        Some(RefTarget::Symbolic(name)) => Ok(Some(name)),
936        _ => Ok(None),
937    }
938}
939
940/// Build the protocol-v2 `ls-refs` records for the repository at `git_dir`,
941/// honoring the request's `ref-prefix`, `peel`, `symrefs`, and `unborn`
942/// arguments. Mirrors `ls-refs.c::ls_refs`.
943fn local_ls_refs_v2_records(
944    git_dir: &Path,
945    format: ObjectFormat,
946    request: &ProtocolV2LsRefsRequest,
947) -> Result<Vec<ProtocolV2LsRefsRecord>> {
948    let store = FileRefStore::new(git_dir, format);
949    let db = FileObjectDatabase::from_git_dir(git_dir, format);
950    let head_symref = head_symref_target(&store)?;
951
952    // Build the (name -> oid, symref) list in git's advertisement order: HEAD
953    // first (when present), then the sorted ref list from `for-each-ref`.
954    let mut entries: Vec<(String, ObjectId, Option<String>)> = Vec::new();
955    if let Some(target) = store.read_ref("HEAD")? {
956        let reference = Ref {
957            name: "HEAD".to_string(),
958            target,
959        };
960        if let Some((oid, _)) = resolve_for_each_ref_target(&store, &reference)? {
961            entries.push(("HEAD".to_string(), oid, head_symref.clone()));
962        } else if request.unborn {
963            // An unborn HEAD (points at a not-yet-created branch) is reported as
964            // an `unborn` record carrying its symref-target.
965            entries.push(("HEAD".to_string(), ObjectId::null(format), head_symref.clone()));
966        }
967    }
968    for reference in store.list_refs()? {
969        let name = reference.name.clone();
970        let Some((oid, symref)) = resolve_for_each_ref_target(&store, &reference)? else {
971            continue;
972        };
973        entries.push((name, oid, symref));
974    }
975
976    let matches_prefix = |name: &str| -> bool {
977        if request.ref_prefixes.is_empty() {
978            return true;
979        }
980        request
981            .ref_prefixes
982            .iter()
983            .any(|prefix| name.starts_with(prefix.as_str()))
984    };
985
986    let mut records = Vec::new();
987    for (name, oid, symref) in entries {
988        if !matches_prefix(&name) {
989            continue;
990        }
991        // Unborn HEAD: only the all-zero placeholder reaches here with `unborn`.
992        if name == "HEAD" && oid == ObjectId::null(format) {
993            records.push(ProtocolV2LsRefsRecord::Unborn {
994                name,
995                symref_target: if request.symrefs { symref } else { None },
996                attributes: Vec::new(),
997            });
998            continue;
999        }
1000        let peeled = if request.peel {
1001            let object = db.read_object(&oid)?;
1002            if object.object_type == ObjectType::Tag {
1003                Some(sley_rev::peel_tags(&db, format, &oid)?)
1004            } else {
1005                None
1006            }
1007        } else {
1008            None
1009        };
1010        let symref_target = if request.symrefs { symref } else { None };
1011        records.push(ProtocolV2LsRefsRecord::Ref(ProtocolV2LsRefsRef {
1012            oid,
1013            name,
1014            peeled,
1015            symref_target,
1016            attributes: Vec::new(),
1017        }));
1018    }
1019    Ok(records)
1020}
1021
1022/// Chunk a raw packfile into sideband channel-1 (`SideBandChannel::Data`)
1023/// pkt-lines for the v2 fetch `packfile` section, matching the upstream
1024/// `0001`-prefixed framing. Each chunk carries at most
1025/// `PKT_LINE_MAX_PAYLOAD_LEN - 1` packfile bytes (the leading byte is the
1026/// channel marker).
1027fn packfile_section_lines(pack: &[u8]) -> Vec<Vec<u8>> {
1028    let chunk = PKT_LINE_MAX_PAYLOAD_LEN - 1;
1029    let mut lines = Vec::new();
1030    for slice in pack.chunks(chunk) {
1031        let mut payload = Vec::with_capacity(slice.len() + 1);
1032        payload.push(1u8); // SideBandChannel::Data
1033        payload.extend_from_slice(slice);
1034        lines.push(payload);
1035    }
1036    lines
1037}
1038
1039/// Build the protocol-v2 `fetch` response sections for a request against the
1040/// repository at `git_dir`. Mirrors `upload-pack.c::upload_pack_v2`'s
1041/// stateless single-round behavior: the client always sends `done` (the v2
1042/// clone/fetch path negotiates haves up front and finishes with `done`), so the
1043/// acknowledgments section is omitted and the response is just the packfile.
1044fn local_fetch_v2_sections(
1045    git_dir: &Path,
1046    format: ObjectFormat,
1047    request: &ProtocolV2FetchRequest,
1048) -> Result<Vec<ProtocolV2FetchResponseSection>> {
1049    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1050
1051    let mut sections = Vec::new();
1052
1053    // Acknowledgments: per gitprotocol-v2, when the client sends `done` the
1054    // acknowledgments section MUST be omitted. Without `done` (multi-round
1055    // negotiation) we answer NAK/ACK for the haves we have in common; the v2
1056    // file:// client always finishes with `done` so this branch is the
1057    // negotiation fallback.
1058    if !request.done {
1059        let mut acks: Vec<ProtocolV2FetchAcknowledgment> = Vec::new();
1060        for have in &request.haves {
1061            if db.contains(have)? {
1062                acks.push(ProtocolV2FetchAcknowledgment::Ack(*have));
1063            }
1064        }
1065        if acks.is_empty() {
1066            acks.push(ProtocolV2FetchAcknowledgment::Nak);
1067        }
1068        sections.push(ProtocolV2FetchResponseSection::Acknowledgments(acks));
1069        // Without `done` and no `ready`, the server stops here to let the
1070        // client continue negotiating; it would re-issue fetch with `done`.
1071        if !request.wait_for_done {
1072            return Ok(sections);
1073        }
1074    }
1075
1076    // Wanted-refs: resolve each `want-ref <name>` to its current oid.
1077    if !request.want_refs.is_empty() {
1078        let store = FileRefStore::new(git_dir, format);
1079        let mut wanted = Vec::new();
1080        for name in &request.want_refs {
1081            let reference = Ref {
1082                name: name.clone(),
1083                target: store
1084                    .read_ref(name)?
1085                    .ok_or_else(|| GitError::not_found(format!("want-ref {name}")))?,
1086            };
1087            let (oid, _) = resolve_for_each_ref_target(&store, &reference)?
1088                .ok_or_else(|| GitError::not_found(format!("want-ref {name}")))?;
1089            wanted.push(sley_protocol::ProtocolV2FetchWantedRef {
1090                oid,
1091                name: name.clone(),
1092            });
1093        }
1094        sections.push(ProtocolV2FetchResponseSection::WantedRefs(wanted));
1095    }
1096
1097    // Resolve want-refs into concrete wants for the pack walk.
1098    let mut wants: Vec<ObjectId> = request.wants.clone();
1099    if !request.want_refs.is_empty() {
1100        if let Some(ProtocolV2FetchResponseSection::WantedRefs(wanted)) = sections
1101            .iter()
1102            .find(|s| matches!(s, ProtocolV2FetchResponseSection::WantedRefs(_)))
1103        {
1104            for w in wanted {
1105                wants.push(w.oid);
1106            }
1107        }
1108    }
1109
1110    // Packfile section: build the reachable pack excluding the client's haves.
1111    let mut known_haves: Vec<ObjectId> = Vec::new();
1112    for have in &request.haves {
1113        if db.contains(have)? {
1114            known_haves.push(*have);
1115        }
1116    }
1117    let excluded = collect_reachable_object_ids(&db, format, known_haves)?;
1118    let pack = build_reachable_pack(&db, format, wants, &excluded)?
1119        .map(|pack| pack.pack)
1120        .unwrap_or_default();
1121
1122    sections.push(ProtocolV2FetchResponseSection::Packfile(
1123        packfile_section_lines(&pack),
1124    ));
1125    Ok(sections)
1126}
1127
1128/// Serve a protocol-v2 upload-pack session over `reader`/`writer` for the
1129/// repository at `git_dir`. Writes the capability advertisement, then loops
1130/// reading `command=` requests (`ls-refs` / `fetch`) until the client closes
1131/// the connection (EOF). Mirrors `upload-pack.c::upload_pack_v2` driven by
1132/// `serve.c`.
1133pub fn serve_upload_pack_v2(
1134    git_dir: &Path,
1135    format: ObjectFormat,
1136    reader: &mut impl std::io::Read,
1137    writer: &mut impl std::io::Write,
1138) -> Result<()> {
1139    let handshake = TransportHandshake {
1140        protocol: ProtocolVersion::V2,
1141        capabilities: upload_pack_v2_capabilities(format),
1142    };
1143    write_protocol_v2_advertisement(writer, &handshake)?;
1144    writer.flush()?;
1145
1146    loop {
1147        let request = match read_protocol_v2_command_request(reader) {
1148            Ok(request) => request,
1149            // EOF / a lone flush after the advertisement ends the session: the
1150            // client disconnected (e.g. `ls-remote` reads the refs and leaves).
1151            Err(_) => break,
1152        };
1153        match request.command.as_str() {
1154            "ls-refs" => {
1155                let ls_refs = ProtocolV2LsRefsRequest::from_command_request(&request)?;
1156                let records = local_ls_refs_v2_records(git_dir, format, &ls_refs)?;
1157                write_protocol_v2_ls_refs_response(writer, &records)?;
1158                writer.flush()?;
1159            }
1160            "fetch" => {
1161                let fetch = ProtocolV2FetchRequest::from_command_request(format, &request)?;
1162                let sections = local_fetch_v2_sections(git_dir, format, &fetch)?;
1163                write_protocol_v2_fetch_response(writer, &sections)?;
1164                writer.flush()?;
1165            }
1166            other => {
1167                return Err(GitError::InvalidFormat(format!(
1168                    "unsupported protocol v2 command {other}"
1169                )));
1170            }
1171        }
1172    }
1173    Ok(())
1174}