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