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::io::{Cursor, ErrorKind, Read};
15use std::path::Path;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use sley_config::GitConfig;
19use sley_core::{
20    Capability, GitError, ObjectFormat, ObjectId, Result, UPSTREAM_GIT_COMPAT_VERSION,
21};
22use sley_object::{Commit, ObjectType, Tag};
23use sley_odb::{
24    FileObjectDatabase, ObjectReader, RawPackInstallOptions, build_and_install_reachable_pack,
25    build_and_install_reachable_pack_filtered, build_reachable_pack, collect_reachable_object_ids,
26};
27use sley_protocol::{
28    PKT_LINE_MAX_PAYLOAD_LEN, ProtocolV2FetchAcknowledgment, ProtocolV2FetchFeatures,
29    ProtocolV2FetchRequest, ProtocolV2FetchResponseSection, ProtocolV2FetchShallowInfo,
30    ProtocolV2LsRefsFeatures, ProtocolV2LsRefsRecord, ProtocolV2LsRefsRef, ProtocolV2LsRefsRequest,
31    ProtocolVersion, ReceivePackCommand, ReceivePackCommandStatus, ReceivePackFeatures,
32    ReceivePackPushRequest, ReceivePackPushRequestHeader, ReceivePackReportStatus,
33    ReceivePackRequest, ReceivePackUnpackStatus, RefAdvertisement, SideBandChannel, SideBandPacket,
34    TransportHandshake, UploadPackFeatures, UploadPackNegotiationRequest,
35    UploadPackPackfileResponse, UploadPackRawPackfileResponse, UploadPackRequest,
36    apply_receive_pack_push_request, build_upload_pack_raw_packfile_response,
37    classify_protocol_v2_command_request, encode_protocol_v2_fetch_capability,
38    encode_protocol_v2_ls_refs_capability, encode_receive_pack_features,
39    encode_upload_pack_features, read_protocol_v2_command_request,
40    read_upload_pack_negotiation_request, read_upload_pack_request,
41    validate_receive_pack_push_request_features, write_protocol_v2_advertisement,
42    write_protocol_v2_fetch_response, write_protocol_v2_ls_refs_response,
43    write_upload_pack_negotiation_request, write_upload_pack_request,
44};
45use sley_refs::{
46    DeleteRef, FileRefStore, Ref, RefDeletePrecondition, RefPrecondition, RefTarget, ReflogEntry,
47};
48
49/// The all-zero object id for `format`, used for the synthetic
50/// `capabilities^{}` advertisement when a repository has no refs.
51fn zero_oid(format: ObjectFormat) -> Result<ObjectId> {
52    Ok(ObjectId::null(format))
53}
54
55/// Resolve a (possibly symbolic) ref target to its object id, following up to
56/// five levels of symbolic indirection, returning the first symbolic name seen.
57fn resolve_for_each_ref_target(
58    store: &FileRefStore,
59    reference: &Ref,
60) -> Result<Option<(ObjectId, Option<String>)>> {
61    let mut target = reference.target.clone();
62    let mut symref = None;
63    for _ in 0..5 {
64        match target {
65            RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
66            RefTarget::Symbolic(name) => {
67                symref.get_or_insert_with(|| name.clone());
68                let Some(next) = store.read_ref(&name)? else {
69                    return Ok(None);
70                };
71                target = next;
72            }
73        }
74    }
75    Ok(None)
76}
77
78/// The upload-pack capabilities advertised for the repository at `git_dir`:
79/// the object format, side-band-64k, and a `HEAD` symref hint if present.
80pub fn upload_pack_features(git_dir: &Path, format: ObjectFormat) -> Result<UploadPackFeatures> {
81    let store = FileRefStore::new(git_dir, format);
82    let mut symrefs = Vec::new();
83    if let Some(RefTarget::Symbolic(target)) = store.read_ref("HEAD")? {
84        symrefs.push(format!("HEAD:{target}"));
85    }
86    Ok(UploadPackFeatures {
87        object_format: Some(format),
88        side_band_64k: true,
89        symrefs,
90        ..UploadPackFeatures::default()
91    })
92}
93
94/// Whether the client negotiated a side-band channel for the packfile response.
95pub fn upload_pack_request_uses_sideband(request: &UploadPackRequest) -> bool {
96    request
97        .capabilities
98        .iter()
99        .any(|capability| matches!(capability.name.as_str(), "side-band" | "side-band-64k"))
100}
101
102/// Re-frame a raw packfile response as side-band data packets, chunked to the
103/// pkt-line payload limit (less the one-byte channel prefix).
104pub fn upload_pack_sideband_response(
105    response: UploadPackRawPackfileResponse,
106) -> UploadPackPackfileResponse {
107    let mut sideband = Vec::new();
108    let chunk_len = PKT_LINE_MAX_PAYLOAD_LEN - 1;
109    for chunk in response.packfile.chunks(chunk_len) {
110        sideband.push(SideBandPacket {
111            channel: SideBandChannel::Data,
112            data: chunk.to_vec(),
113        });
114    }
115    UploadPackPackfileResponse {
116        acknowledgments: response.acknowledgments,
117        sideband,
118    }
119}
120
121/// Encode `features` into the leading ref advertisement's capability list,
122/// inserting a synthetic `capabilities^{}` entry when there are no refs.
123pub fn attach_upload_pack_capabilities(
124    advertisements: &mut Vec<RefAdvertisement>,
125    format: ObjectFormat,
126    features: &UploadPackFeatures,
127) -> Result<()> {
128    let capabilities = encode_upload_pack_features(features)?;
129    if let Some(first) = advertisements.first_mut() {
130        first.capabilities = capabilities;
131    } else {
132        advertisements.push(RefAdvertisement {
133            oid: zero_oid(format)?,
134            name: "capabilities^{}".into(),
135            capabilities,
136        });
137    }
138    Ok(())
139}
140
141/// Serve an upload-pack request from the repository at `git_dir`: build the
142/// packfile that carries every reachable object the client `wants` but does not
143/// already `haves`, framed as a raw (non-side-band) response.
144pub fn upload_pack_from_local_repository(
145    git_dir: &Path,
146    format: ObjectFormat,
147    features: &UploadPackFeatures,
148    request: UploadPackRequest,
149    haves: HashSet<ObjectId>,
150) -> Result<UploadPackRawPackfileResponse> {
151    let db = FileObjectDatabase::from_git_dir(git_dir, format);
152    build_upload_pack_raw_packfile_response(
153        features,
154        request,
155        haves,
156        |oid| db.contains(oid),
157        |wants, known_haves| {
158            let excluded = collect_reachable_object_ids(&db, format, known_haves)?;
159            build_reachable_pack(&db, format, wants, &excluded)
160                .map(|pack| pack.map(|pack| pack.pack))
161        },
162    )
163}
164
165/// The receive-pack capabilities advertised for a local repository: report
166/// status, ref deletion, ofs-delta, push-options, quiet, no-thin, and the object
167/// format.
168pub fn receive_pack_features(format: ObjectFormat) -> ReceivePackFeatures {
169    ReceivePackFeatures {
170        report_status: true,
171        delete_refs: true,
172        ofs_delta: true,
173        push_options: true,
174        quiet: true,
175        no_thin: true,
176        object_format: Some(format),
177        ..ReceivePackFeatures::default()
178    }
179}
180
181/// Whether the client negotiated `push-options` (so the caller must read the
182/// push-option section that follows the command list).
183pub fn receive_pack_request_uses_push_options(request: &ReceivePackRequest) -> bool {
184    request
185        .capabilities
186        .iter()
187        .any(|capability| capability.name == "push-options")
188}
189
190/// Encode `features` into the leading ref advertisement's capability list,
191/// inserting a synthetic `capabilities^{}` entry when there are no refs.
192pub fn attach_receive_pack_capabilities(
193    advertisements: &mut Vec<RefAdvertisement>,
194    format: ObjectFormat,
195    features: &ReceivePackFeatures,
196) -> Result<()> {
197    let capabilities = encode_receive_pack_features(features)?;
198    if let Some(first) = advertisements.first_mut() {
199        first.capabilities = capabilities;
200    } else {
201        advertisements.push(RefAdvertisement {
202            oid: zero_oid(format)?,
203            name: "capabilities^{}".into(),
204            capabilities,
205        });
206    }
207    Ok(())
208}
209
210/// Apply a receive-pack push to the repository at `remote_git_dir`: install the
211/// incoming packfile and execute the ref creations/updates/deletions, returning
212/// the report-status describing what happened.
213pub fn receive_pack_into_local_repository(
214    remote_git_dir: &Path,
215    format: ObjectFormat,
216    request: &ReceivePackPushRequest,
217) -> Result<ReceivePackReportStatus> {
218    let remote_store = FileRefStore::new(remote_git_dir, format);
219    let remote_db = FileObjectDatabase::from_git_dir(remote_git_dir, format);
220    let deletes_applied_with_updates = std::cell::RefCell::new(HashSet::<String>::new());
221    apply_receive_pack_push_request(
222        &receive_pack_features(format),
223        request,
224        |name| match remote_store.read_ref(name)? {
225            Some(RefTarget::Direct(oid)) => Ok(Some(oid)),
226            Some(RefTarget::Symbolic(_)) | None => Ok(None),
227        },
228        |packfile| {
229            let mut reader = packfile;
230            remote_db
231                .install_raw_pack_from_reader(&mut reader)
232                .map(|_| ())
233        },
234        |oid| remote_db.contains(oid),
235        |commands| {
236            let applied = apply_receive_pack_ref_transaction(
237                remote_git_dir,
238                format,
239                &remote_store,
240                commands,
241                &request.commands.commands,
242            )?;
243            deletes_applied_with_updates.borrow_mut().extend(applied);
244            Ok(())
245        },
246        |command| {
247            if deletes_applied_with_updates
248                .borrow()
249                .contains(command.name.as_str())
250            {
251                return Ok(());
252            }
253            remote_store
254                .delete_ref_checked(DeleteRef {
255                    name: command.name.clone(),
256                    expected_old: (!command.old_id.is_null()).then_some(command.old_id),
257                    reflog: None,
258                })
259                .map(|_| ())
260                .map_err(|err| GitError::Transaction(err.to_string()))
261        },
262    )
263}
264
265/// Apply a receive-pack push while streaming the optional incoming packfile from
266/// `pack_reader` into the object database. This mirrors
267/// [`receive_pack_into_local_repository`] but avoids materializing the pack as a
268/// `Vec<u8>` in stdio/SSH server paths.
269pub fn receive_pack_stream_into_local_repository<R: Read>(
270    remote_git_dir: &Path,
271    format: ObjectFormat,
272    header: &ReceivePackPushRequestHeader,
273    pack_reader: &mut R,
274) -> Result<ReceivePackReportStatus> {
275    let remote_store = FileRefStore::new(remote_git_dir, format);
276    let remote_db = FileObjectDatabase::from_git_dir(remote_git_dir, format);
277    let pack_prefix = read_optional_pack_prefix(pack_reader)?;
278    let validation_request = ReceivePackPushRequest {
279        commands: header.commands.clone(),
280        push_options: header.push_options.clone(),
281        packfile: pack_prefix.clone().unwrap_or_default(),
282    };
283    validate_receive_pack_push_request_features(
284        &receive_pack_features(format),
285        &validation_request,
286    )?;
287
288    let deletes_applied_with_updates = std::cell::RefCell::new(HashSet::<String>::new());
289    for command in header
290        .commands
291        .commands
292        .iter()
293        .filter(|command| command.new_id.is_null())
294    {
295        let current = match remote_store.read_ref(&command.name)? {
296            Some(RefTarget::Direct(oid)) => Some(oid),
297            Some(RefTarget::Symbolic(_)) | None => None,
298        };
299        if !command.old_id.is_null() && current != Some(command.old_id.clone()) {
300            return Err(GitError::Transaction(format!(
301                "expected ref {} to match",
302                command.name
303            )));
304        }
305    }
306
307    let updates = header
308        .commands
309        .commands
310        .iter()
311        .filter(|command| !command.new_id.is_null())
312        .cloned()
313        .collect::<Vec<_>>();
314    if !updates.is_empty() {
315        if let Some(prefix) = pack_prefix {
316            let mut stream = Cursor::new(prefix).chain(pack_reader);
317            remote_db
318                .install_raw_pack_from_reader(&mut stream)
319                .map(|_| ())?;
320        }
321        for command in &updates {
322            if !remote_db.contains(&command.new_id)? {
323                return Err(GitError::InvalidObject(format!(
324                    "receive-pack packfile did not provide {}",
325                    command.new_id
326                )));
327            }
328        }
329        let applied = apply_receive_pack_ref_transaction(
330            remote_git_dir,
331            format,
332            &remote_store,
333            &updates,
334            &header.commands.commands,
335        )?;
336        deletes_applied_with_updates.borrow_mut().extend(applied);
337    }
338
339    for command in header
340        .commands
341        .commands
342        .iter()
343        .filter(|command| command.new_id.is_null())
344    {
345        if deletes_applied_with_updates
346            .borrow()
347            .contains(command.name.as_str())
348        {
349            continue;
350        }
351        remote_store
352            .delete_ref_checked(DeleteRef {
353                name: command.name.clone(),
354                expected_old: (!command.old_id.is_null()).then_some(command.old_id),
355                reflog: None,
356            })
357            .map(|_| ())
358            .map_err(|err| GitError::Transaction(err.to_string()))?;
359    }
360
361    Ok(ReceivePackReportStatus {
362        unpack: ReceivePackUnpackStatus::Ok,
363        commands: header
364            .commands
365            .commands
366            .iter()
367            .map(|command| ReceivePackCommandStatus::Ok {
368                name: command.name.clone(),
369            })
370            .collect(),
371    })
372}
373
374fn read_optional_pack_prefix(reader: &mut impl Read) -> Result<Option<Vec<u8>>> {
375    let mut prefix = [0u8; 4];
376    loop {
377        match reader.read(&mut prefix[..1]) {
378            Ok(0) => return Ok(None),
379            Ok(1) => break,
380            Ok(_) => unreachable!("one-byte read returned more than one byte"),
381            Err(err) if err.kind() == ErrorKind::Interrupted => {}
382            Err(err) => return Err(err.into()),
383        }
384    }
385    reader.read_exact(&mut prefix[1..])?;
386    if &prefix != b"PACK" {
387        return Err(GitError::InvalidFormat(
388            "receive-pack packfile must start with PACK".into(),
389        ));
390    }
391    Ok(Some(prefix.to_vec()))
392}
393
394fn receive_pack_log_all_ref_updates(git_dir: &Path) -> bool {
395    let Ok(config) = fs::read_to_string(git_dir.join("config")) else {
396        return false;
397    };
398    let mut in_core = false;
399    for raw_line in config.lines() {
400        let line = raw_line.trim();
401        if line.starts_with('[') && line.ends_with(']') {
402            in_core = line.eq_ignore_ascii_case("[core]");
403            continue;
404        }
405        if !in_core || line.starts_with('#') || line.starts_with(';') {
406            continue;
407        }
408        let Some((name, value)) = line.split_once('=') else {
409            continue;
410        };
411        if name.trim().eq_ignore_ascii_case("logallrefupdates") {
412            return matches!(
413                value.trim().trim_matches('"').to_ascii_lowercase().as_str(),
414                "true" | "yes" | "on" | "1" | "always"
415            );
416        }
417    }
418    false
419}
420
421fn receive_pack_should_write_reflog(refname: &str) -> bool {
422    refname == "HEAD"
423        || refname.starts_with("refs/heads/")
424        || refname.starts_with("refs/remotes/")
425        || refname.starts_with("refs/notes/")
426}
427
428fn receive_pack_reflog_entry(
429    format: ObjectFormat,
430    old_oid: ObjectId,
431    new_oid: ObjectId,
432) -> ReflogEntry {
433    let old_oid = if old_oid.is_null() {
434        ObjectId::null(format)
435    } else {
436        old_oid
437    };
438    ReflogEntry {
439        old_oid,
440        new_oid,
441        committer: receive_pack_reflog_committer(),
442        message: b"push".to_vec(),
443    }
444}
445
446fn receive_pack_reflog_committer() -> Vec<u8> {
447    let seconds = SystemTime::now()
448        .duration_since(UNIX_EPOCH)
449        .map(|duration| duration.as_secs())
450        .unwrap_or(0);
451    format!("Git Rs <sley@example.invalid> {seconds} +0000").into_bytes()
452}
453
454/// Apply a local receive-pack request whose pack can be built from `source_db`
455/// after receive-pack preflight checks pass.
456///
457/// This keeps local push on the same validation path as raw receive-pack while
458/// avoiding a raw-pack round trip: the install closure builds the reachable
459/// pack and installs the generated pack/index directly.
460pub fn receive_pack_reachable_pack_into_local_repository(
461    remote_git_dir: &Path,
462    format: ObjectFormat,
463    request: &ReceivePackPushRequest,
464    source_db: &FileObjectDatabase,
465    starts: Vec<ObjectId>,
466    excluded: HashSet<ObjectId>,
467) -> Result<ReceivePackReportStatus> {
468    let remote_store = FileRefStore::new(remote_git_dir, format);
469    let remote_db = FileObjectDatabase::from_git_dir(remote_git_dir, format);
470    let mut starts = Some(starts);
471    let deletes_applied_with_updates = std::cell::RefCell::new(HashSet::<String>::new());
472    apply_receive_pack_push_request(
473        &receive_pack_features(format),
474        request,
475        |name| match remote_store.read_ref(name)? {
476            Some(RefTarget::Direct(oid)) => Ok(Some(oid)),
477            Some(RefTarget::Symbolic(_)) | None => Ok(None),
478        },
479        |_| {
480            let starts = starts.take().ok_or_else(|| {
481                GitError::InvalidFormat("receive-pack attempted to install pack twice".into())
482            })?;
483            build_and_install_reachable_pack(
484                source_db,
485                &remote_db,
486                format,
487                starts,
488                &excluded,
489                RawPackInstallOptions { promisor: false },
490            )?;
491            Ok(())
492        },
493        |oid| remote_db.contains(oid),
494        |commands| {
495            let applied = apply_receive_pack_ref_transaction(
496                remote_git_dir,
497                format,
498                &remote_store,
499                commands,
500                &request.commands.commands,
501            )?;
502            deletes_applied_with_updates.borrow_mut().extend(applied);
503            Ok(())
504        },
505        |command| {
506            if deletes_applied_with_updates
507                .borrow()
508                .contains(command.name.as_str())
509            {
510                return Ok(());
511            }
512            remote_store
513                .delete_ref_checked(DeleteRef {
514                    name: command.name.clone(),
515                    expected_old: (!command.old_id.is_null()).then_some(command.old_id),
516                    reflog: None,
517                })
518                .map(|_| ())
519                .map_err(|err| GitError::Transaction(err.to_string()))
520        },
521    )
522}
523
524fn apply_receive_pack_ref_transaction(
525    remote_git_dir: &Path,
526    format: ObjectFormat,
527    store: &FileRefStore,
528    updates: &[ReceivePackCommand],
529    all_commands: &[ReceivePackCommand],
530) -> Result<HashSet<String>> {
531    let updates = canonical_receive_pack_update_commands(store, updates)?;
532    let deletes = all_commands
533        .iter()
534        .filter(|command| command.new_id.is_null())
535        .collect::<Vec<_>>();
536    let mut tx = store.transaction();
537    for command in &deletes {
538        tx.delete_with_precondition(
539            command.name.clone(),
540            RefDeletePrecondition::Direct((!command.old_id.is_null()).then_some(command.old_id)),
541            None,
542        );
543    }
544    let log_updates = receive_pack_log_all_ref_updates(remote_git_dir);
545    for command in &updates {
546        let precondition = if command.old_id.is_null() {
547            RefPrecondition::MustNotExist
548        } else {
549            RefPrecondition::MustExistAndMatch(RefTarget::Direct(command.old_id))
550        };
551        let reflog = if log_updates && receive_pack_should_write_reflog(&command.name) {
552            Some(receive_pack_reflog_entry(
553                format,
554                command.old_id,
555                command.new_id,
556            ))
557        } else {
558            None
559        };
560        tx.update_to(
561            command.name.clone(),
562            RefTarget::Direct(command.new_id),
563            precondition,
564            reflog,
565        );
566    }
567    tx.commit()?;
568    Ok(deletes
569        .into_iter()
570        .map(|command| command.name.clone())
571        .collect())
572}
573
574fn canonical_receive_pack_update_commands(
575    store: &FileRefStore,
576    commands: &[ReceivePackCommand],
577) -> Result<Vec<ReceivePackCommand>> {
578    let mut by_actual = HashMap::<String, ObjectId>::new();
579    let mut canonical = Vec::with_capacity(commands.len());
580    for command in commands {
581        let name = match store.read_ref(&command.name)? {
582            Some(RefTarget::Symbolic(target)) => target,
583            Some(RefTarget::Direct(_)) | None => command.name.clone(),
584        };
585        if let Some(existing) = by_actual.get(&name) {
586            if existing != &command.new_id {
587                return Err(GitError::Command("refusing inconsistent update".into()));
588            }
589        } else {
590            by_actual.insert(name.clone(), command.new_id);
591        }
592        canonical.push(ReceivePackCommand {
593            old_id: command.old_id,
594            new_id: command.new_id,
595            name,
596        });
597    }
598    Ok(canonical)
599}
600
601/// The ref advertisements a local repository would send to a fetching client:
602/// `HEAD` (if resolvable) followed by every ref, each resolved to its object id.
603pub fn local_fetch_advertisements(
604    git_dir: &Path,
605    format: ObjectFormat,
606) -> Result<Vec<RefAdvertisement>> {
607    let store = FileRefStore::new(git_dir, format);
608    let mut advertisements = Vec::new();
609    if let Some(target) = store.read_ref("HEAD")? {
610        let reference = Ref {
611            name: "HEAD".to_string(),
612            target,
613        };
614        if let Some((oid, _)) = resolve_for_each_ref_target(&store, &reference)? {
615            advertisements.push(RefAdvertisement {
616                oid,
617                name: reference.name,
618                capabilities: Vec::new(),
619            });
620        }
621    }
622    for reference in store.list_refs()? {
623        let Some((oid, _)) = resolve_for_each_ref_target(&store, &reference)? else {
624            continue;
625        };
626        advertisements.push(RefAdvertisement {
627            oid,
628            name: reference.name,
629            capabilities: Vec::new(),
630        });
631    }
632    Ok(advertisements)
633}
634
635/// The object ids the local repository can offer as `have`s during negotiation.
636/// Ref tips are offered first, then every object visible through the local
637/// object database, including alternates recorded in `objects/info/alternates`.
638pub fn local_have_oids(git_dir: &Path, format: ObjectFormat) -> Result<Vec<ObjectId>> {
639    let mut seen = HashSet::new();
640    let mut haves = Vec::new();
641    for advertisement in local_fetch_advertisements(git_dir, format)? {
642        if seen.insert(advertisement.oid) {
643            haves.push(advertisement.oid);
644        }
645    }
646    let db = FileObjectDatabase::from_git_dir(git_dir, format);
647    for oid in db.object_ids()? {
648        if seen.insert(oid) {
649            haves.push(oid);
650        }
651    }
652    Ok(haves)
653}
654
655/// The in-process upload-pack's plan for a `deepen` (shallow) local fetch:
656/// which `shallow`/`unshallow` updates to report, which commits the pack walk
657/// must stop at, and which extra tips become packable because the client's
658/// boundary moved.
659///
660/// Mirrors upstream `upload-pack.c::deepen` + `shallow.c::get_shallow_commits`.
661#[derive(Debug, Clone)]
662pub struct LocalDeepenPlan {
663    /// The requested deepen depth (`--depth N`; [`INFINITE_DEPTH`] for
664    /// `--unshallow` and for the implicit deepen a shallow server runs on a
665    /// plain fetch; `0` for the deepen-since/deepen-not rev-list modes).
666    pub depth: u32,
667    /// The request carried `deepen-since` (trace2 `fetch-info` parity).
668    pub deepen_since: bool,
669    /// Number of `deepen-not` entries in the request (trace2 parity).
670    pub deepen_not: usize,
671    /// The client's existing shallow boundary (`$GIT_DIR/shallow`), replayed as
672    /// `shallow` lines in the upload-pack request.
673    pub client_shallow: Vec<ObjectId>,
674    /// The server's `shallow`/`unshallow` updates the client must fold into
675    /// `$GIT_DIR/shallow` after the pack lands (see [`crate::apply_shallow_info`]).
676    pub shallow_info: Vec<ProtocolV2FetchShallowInfo>,
677    /// Out-of-boundary commits (the parents of boundary commits that are not
678    /// themselves within the boundary): excluding these from the pack walk
679    /// truncates history at the boundary while keeping every tree/blob of the
680    /// boundary commits themselves.
681    pub excluded: HashSet<ObjectId>,
682    /// Parents of client-shallow commits this deepen un-shallowed, added as
683    /// extra pack tips so the newly visible history is sent (upload-pack adds
684    /// them to `want_obj` in `send_unshallow`).
685    pub extra_wants: Vec<ObjectId>,
686}
687
688/// Dereference `oid` through any chain of annotated tags to a commit, or `None`
689/// when it ultimately points at a tree or blob (`deref_tag` in upstream
690/// `shallow.c`'s boundary walk).
691fn peel_to_commit<R: ObjectReader>(
692    remote_db: &R,
693    format: ObjectFormat,
694    oid: &ObjectId,
695) -> Result<Option<ObjectId>> {
696    let mut oid = *oid;
697    loop {
698        let object = remote_db.read_object(&oid)?;
699        match object.object_type {
700            ObjectType::Commit => return Ok(Some(oid)),
701            ObjectType::Tag => oid = Tag::parse_ref(format, &object.body)?.object,
702            _ => return Ok(None),
703        }
704    }
705}
706
707/// Compute the deepen plan for a shallow local fetch, mirroring upstream
708/// `shallow.c::get_shallow_commits`: a breadth-first minimum-depth walk from the
709/// (tag-dereferenced) `heads` — the primary planned tips, upload-pack's
710/// `want_obj`, NOT auto-followed tags — where tips enter at depth 0 and a commit
711/// processed at depth `d` is a boundary commit when `d + 1 >= depth` (it is
712/// packed, but its parents are not walked).
713///
714/// `client_shallow` is the client's current boundary: boundary commits the
715/// client already has are not re-reported (`send_shallow` skips
716/// `CLIENT_SHALLOW`), and client-shallow commits now within the boundary are
717/// reported as `unshallow` with their parents returned as extra pack tips
718/// (`send_unshallow`).
719pub fn compute_local_deepen<R: ObjectReader>(
720    remote_db: &R,
721    format: ObjectFormat,
722    heads: &[ObjectId],
723    client_shallow: Vec<ObjectId>,
724    depth: u32,
725    deepen_relative: bool,
726) -> Result<LocalDeepenPlan> {
727    // `--deepen=N`: the boundary moves N commits past the client's current
728    // boundary (upstream `get_shallows_depth` + `depth +=`).
729    let depth = if deepen_relative && depth < INFINITE_DEPTH {
730        depth.saturating_add(client_shallow_min_depth(
731            remote_db,
732            format,
733            heads,
734            &client_shallow,
735        )?)
736    } else {
737        depth
738    };
739    let mut min_depth: HashMap<ObjectId, u32> = HashMap::new();
740    let mut queue: VecDeque<ObjectId> = VecDeque::new();
741    for head in heads {
742        let Some(commit) = peel_to_commit(remote_db, format, head)? else {
743            continue;
744        };
745        if let std::collections::hash_map::Entry::Vacant(entry) = min_depth.entry(commit) {
746            entry.insert(0);
747            queue.push_back(commit);
748        }
749    }
750    // FIFO processing with uniform edge weight makes the first visit the
751    // minimum depth, so each commit is processed exactly once and expands its
752    // parents only when it is within the boundary — the same fixpoint as
753    // upstream's decrease-key re-walks.
754    let mut boundary = Vec::new();
755    let mut boundary_parents = HashSet::new();
756    while let Some(oid) = queue.pop_front() {
757        let commit_depth = min_depth[&oid];
758        let object = remote_db.read_object(&oid)?;
759        let parents = sley_odb::grafted_parents(
760            remote_db,
761            &oid,
762            Commit::parse_ref(format, &object.body)?.parents,
763        );
764        // A commit is boundary when the requested depth cuts at it, or when
765        // the server's own history is cut at it (a shallow server reports its
766        // graft points to the client — upstream `get_shallows_or_depth`).
767        if (depth != INFINITE_DEPTH && commit_depth + 1 >= depth)
768            || remote_db.is_shallow_graft(&oid)
769        {
770            boundary.push(oid);
771            boundary_parents.extend(parents);
772            continue;
773        }
774        for parent in parents {
775            if let std::collections::hash_map::Entry::Vacant(entry) = min_depth.entry(parent) {
776                entry.insert(commit_depth + 1);
777                queue.push_back(parent);
778            }
779        }
780    }
781    // A boundary commit's parent can itself be within the boundary via a
782    // shorter path (and is then packed); only parents the walk never reached
783    // are excluded.
784    let excluded = boundary_parents
785        .into_iter()
786        .filter(|parent| !min_depth.contains_key(parent))
787        .collect::<HashSet<_>>();
788
789    let client: HashSet<ObjectId> = client_shallow.iter().copied().collect();
790    let boundary_set: HashSet<ObjectId> = boundary.iter().copied().collect();
791    let mut shallow_info = Vec::new();
792    for oid in &boundary {
793        if !client.contains(oid) {
794            shallow_info.push(ProtocolV2FetchShallowInfo::Shallow(*oid));
795        }
796    }
797    let mut extra_wants = Vec::new();
798    for oid in &client_shallow {
799        // A client-shallow commit is unshallowed when the walk reached it as
800        // a non-boundary commit (upstream `send_unshallow`: NOT_SHALLOW set).
801        let unshallowed = min_depth.contains_key(oid) && !boundary_set.contains(oid);
802        if !unshallowed {
803            continue;
804        }
805        shallow_info.push(ProtocolV2FetchShallowInfo::Unshallow(*oid));
806        let object = remote_db.read_object(oid)?;
807        extra_wants.extend(sley_odb::grafted_parents(
808            remote_db,
809            oid,
810            Commit::parse_ref(format, &object.body)?.parents,
811        ));
812    }
813    Ok(LocalDeepenPlan {
814        depth,
815        deepen_since: false,
816        deepen_not: 0,
817        client_shallow,
818        shallow_info,
819        excluded,
820        extra_wants,
821    })
822}
823
824/// Upstream `INFINITE_DEPTH`: `--unshallow`, and the implicit deepen a shallow
825/// server runs for a plain fetch so its graft points reach the client.
826pub const INFINITE_DEPTH: u32 = 0x7fff_ffff;
827
828/// Upstream `get_shallows_depth`: the minimum depth (head = 1) at which the
829/// walk from `heads` meets one of the client's shallow points, or 0 when it
830/// never does. Used to make `--deepen=N` relative to the current boundary.
831fn client_shallow_min_depth<R: ObjectReader>(
832    remote_db: &R,
833    format: ObjectFormat,
834    heads: &[ObjectId],
835    client_shallow: &[ObjectId],
836) -> Result<u32> {
837    if client_shallow.is_empty() {
838        return Ok(0);
839    }
840    let client: HashSet<ObjectId> = client_shallow.iter().copied().collect();
841    let mut min_depth: HashMap<ObjectId, u32> = HashMap::new();
842    let mut queue: VecDeque<ObjectId> = VecDeque::new();
843    for head in heads {
844        let Some(commit) = peel_to_commit(remote_db, format, head)? else {
845            continue;
846        };
847        if let std::collections::hash_map::Entry::Vacant(entry) = min_depth.entry(commit) {
848            entry.insert(1);
849            queue.push_back(commit);
850        }
851    }
852    let mut best: u32 = 0;
853    while let Some(oid) = queue.pop_front() {
854        let commit_depth = min_depth[&oid];
855        if client.contains(&oid) && (best == 0 || commit_depth < best) {
856            best = commit_depth;
857        }
858        let object = remote_db.read_object(&oid)?;
859        let parents = sley_odb::grafted_parents(
860            remote_db,
861            &oid,
862            Commit::parse_ref(format, &object.body)?.parents,
863        );
864        for parent in parents {
865            if let std::collections::hash_map::Entry::Vacant(entry) = min_depth.entry(parent) {
866                entry.insert(commit_depth + 1);
867                queue.push_back(parent);
868            }
869        }
870    }
871    Ok(best)
872}
873
874/// Deepen plan for the rev-list modes (`--shallow-since`, `--shallow-exclude`),
875/// mirroring upstream `get_shallow_commits_by_rev_list`: the kept set is every
876/// commit reachable from `heads` that is newer than `since` (when given) and
877/// not reachable from a `deepen_not` tip; the boundary is every kept commit
878/// with at least one parent outside the kept set.
879pub fn compute_local_deepen_by_rev_list<R: ObjectReader>(
880    remote_db: &R,
881    format: ObjectFormat,
882    heads: &[ObjectId],
883    client_shallow: Vec<ObjectId>,
884    since: Option<i64>,
885    deepen_not: &[ObjectId],
886) -> Result<LocalDeepenPlan> {
887    // Closure of the deepen-not tips (commits to subtract from the kept set).
888    let mut excluded_not: HashSet<ObjectId> = HashSet::new();
889    let mut queue: VecDeque<ObjectId> = VecDeque::new();
890    for tip in deepen_not {
891        if let Some(commit) = peel_to_commit(remote_db, format, tip)?
892            && excluded_not.insert(commit)
893        {
894            queue.push_back(commit);
895        }
896    }
897    while let Some(oid) = queue.pop_front() {
898        let object = remote_db.read_object(&oid)?;
899        for parent in sley_odb::grafted_parents(
900            remote_db,
901            &oid,
902            Commit::parse_ref(format, &object.body)?.parents,
903        ) {
904            if excluded_not.insert(parent) {
905                queue.push_back(parent);
906            }
907        }
908    }
909
910    let commit_time = |oid: &ObjectId| -> Result<i64> {
911        let object = remote_db.read_object(oid)?;
912        Ok(Commit::parse_ref(format, &object.body)?
913            .committer_signature()
914            .map(|signature| signature.time.seconds)
915            .unwrap_or(0))
916    };
917    let keeps = |oid: &ObjectId| -> Result<bool> {
918        if excluded_not.contains(oid) {
919            return Ok(false);
920        }
921        match since {
922            Some(since) => Ok(commit_time(oid)? >= since),
923            None => Ok(true),
924        }
925    };
926
927    // Kept-set walk: only kept commits are expanded, so the walk never reads
928    // objects past the cut (and stops at server graft points via the seam).
929    let mut kept: HashSet<ObjectId> = HashSet::new();
930    let mut kept_order: Vec<ObjectId> = Vec::new();
931    let mut queue: VecDeque<ObjectId> = VecDeque::new();
932    for head in heads {
933        let Some(commit) = peel_to_commit(remote_db, format, head)? else {
934            continue;
935        };
936        if keeps(&commit)? && kept.insert(commit) {
937            kept_order.push(commit);
938            queue.push_back(commit);
939        }
940    }
941    while let Some(oid) = queue.pop_front() {
942        let object = remote_db.read_object(&oid)?;
943        for parent in sley_odb::grafted_parents(
944            remote_db,
945            &oid,
946            Commit::parse_ref(format, &object.body)?.parents,
947        ) {
948            if !kept.contains(&parent) && keeps(&parent)? {
949                kept.insert(parent);
950                kept_order.push(parent);
951                queue.push_back(parent);
952            }
953        }
954    }
955    if kept.is_empty() {
956        // Upstream `get_shallow_commits_by_rev_list` dies here.
957        return Err(GitError::Command(
958            "no commits selected for shallow requests".into(),
959        ));
960    }
961
962    // Boundary: kept commits with a parent outside the kept set.
963    let mut boundary = Vec::new();
964    let mut boundary_set: HashSet<ObjectId> = HashSet::new();
965    let mut excluded: HashSet<ObjectId> = HashSet::new();
966    for oid in &kept_order {
967        let object = remote_db.read_object(oid)?;
968        let parents = sley_odb::grafted_parents(
969            remote_db,
970            oid,
971            Commit::parse_ref(format, &object.body)?.parents,
972        );
973        let mut is_boundary = false;
974        for parent in parents {
975            if !kept.contains(&parent) {
976                is_boundary = true;
977                excluded.insert(parent);
978            }
979        }
980        if is_boundary && boundary_set.insert(*oid) {
981            boundary.push(*oid);
982        }
983    }
984
985    let client: HashSet<ObjectId> = client_shallow.iter().copied().collect();
986    let mut shallow_info = Vec::new();
987    for oid in &boundary {
988        if !client.contains(oid) {
989            shallow_info.push(ProtocolV2FetchShallowInfo::Shallow(*oid));
990        }
991    }
992    let mut extra_wants = Vec::new();
993    for oid in &client_shallow {
994        let unshallowed = kept.contains(oid) && !boundary_set.contains(oid);
995        if !unshallowed {
996            continue;
997        }
998        shallow_info.push(ProtocolV2FetchShallowInfo::Unshallow(*oid));
999        let object = remote_db.read_object(oid)?;
1000        extra_wants.extend(sley_odb::grafted_parents(
1001            remote_db,
1002            oid,
1003            Commit::parse_ref(format, &object.body)?.parents,
1004        ));
1005    }
1006    Ok(LocalDeepenPlan {
1007        depth: 0,
1008        deepen_since: since.is_some(),
1009        deepen_not: deepen_not.len(),
1010        client_shallow,
1011        shallow_info,
1012        excluded,
1013        extra_wants,
1014    })
1015}
1016
1017/// Fetch `wants` from a local repository at `remote_git_dir` into the repository
1018/// at `git_dir`, round-tripping the request and response through the protocol
1019/// codecs into the in-process upload-pack so the local path exercises the same
1020/// wire format as the networked transports. Objects already present locally are
1021/// skipped; `promisor` selects promisor-pack installation.
1022///
1023/// When `deepen` carries a [`LocalDeepenPlan`] (computed by the caller from the
1024/// primary planned tips via [`compute_local_deepen`]), the fetch is shallow: the
1025/// request replays the client's boundary as `shallow` lines plus a `deepen`
1026/// line, the pack walk stops at the plan's boundary, and the returned
1027/// shallow-info updates must be folded into `$GIT_DIR/shallow` (see
1028/// [`crate::apply_shallow_info`]). Empty for a full fetch.
1029#[allow(clippy::too_many_arguments)]
1030pub fn install_fetch_pack_via_local_upload_pack(
1031    git_dir: &Path,
1032    remote_git_dir: &Path,
1033    format: ObjectFormat,
1034    wants: Vec<ObjectId>,
1035    deepen: Option<&LocalDeepenPlan>,
1036    promisor: bool,
1037    record_promisor_refs: bool,
1038    filter: Option<sley_odb::PackObjectFilter>,
1039    refetch: bool,
1040    unpack_limit: Option<usize>,
1041) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
1042    if wants.is_empty() {
1043        return Ok(Vec::new());
1044    }
1045    let local_db = FileObjectDatabase::from_git_dir(git_dir, format)
1046        .with_promisor_remote_present(repo_has_promisor_remote(git_dir));
1047    let all_wants_present = wants
1048        .iter()
1049        .map(|want| local_db.contains(want))
1050        .collect::<Result<Vec<_>>>()?
1051        .into_iter()
1052        .all(|contains| contains);
1053    let deepen_noop = match deepen {
1054        Some(plan) => plan.shallow_info.is_empty() && plan.extra_wants.is_empty(),
1055        None => true,
1056    };
1057    if all_wants_present && deepen_noop && !refetch {
1058        sley_protocol::trace_packet_write_payload(b"0000");
1059        return Ok(Vec::new());
1060    }
1061
1062    let request = UploadPackRequest {
1063        wants,
1064        filter: filter
1065            .as_ref()
1066            .and_then(local_upload_pack_filter_protocol_spec),
1067        // The `shallow` capability accompanies a deepen request on the wire
1068        // (mirrors the SSH path); a plain fetch keeps its existing wire form.
1069        capabilities: deepen
1070            .map(|_| {
1071                vec![Capability {
1072                    name: "shallow".into(),
1073                    value: None,
1074                }]
1075            })
1076            .unwrap_or_default(),
1077        shallow: deepen
1078            .map(|plan| plan.client_shallow.clone())
1079            .unwrap_or_default(),
1080        deepen: deepen.and_then(|plan| (plan.depth > 0).then_some(plan.depth)),
1081        ..UploadPackRequest::default()
1082    };
1083    let mut encoded_request = Vec::new();
1084    write_upload_pack_request(&mut encoded_request, Some(&request))?;
1085    let decoded_request = read_upload_pack_request(format, &mut encoded_request.as_slice())?
1086        .ok_or_else(|| GitError::InvalidFormat("encoded upload-pack request was empty".into()))?;
1087
1088    // Lazy promisor hydration asks for exact missing objects; negotiating local
1089    // haves would walk the partial client's intentionally-missing blobs.
1090    let direct_promisor_object_fetch = promisor && deepen.is_none() && !record_promisor_refs;
1091    if direct_promisor_object_fetch && local_upload_pack_client_wants_v2(git_dir) {
1092        trace_local_upload_pack_v2_capabilities(remote_git_dir, format);
1093    }
1094    let haves = if refetch || direct_promisor_object_fetch {
1095        Vec::new()
1096    } else {
1097        local_have_oids(git_dir, format)?
1098    };
1099    let negotiation = UploadPackNegotiationRequest { haves, done: true };
1100    let mut encoded_negotiation = Vec::new();
1101    write_upload_pack_negotiation_request(&mut encoded_negotiation, &negotiation)?;
1102    let decoded_negotiation =
1103        read_upload_pack_negotiation_request(format, &mut encoded_negotiation.as_slice())?;
1104    sley_core::trace2::data("negotiation_v2", "total_rounds", 1);
1105
1106    let remote_db = FileObjectDatabase::from_git_dir(remote_git_dir, format);
1107    for want in &decoded_request.wants {
1108        if !remote_db.contains(want)? {
1109            return Err(GitError::InvalidObject(format!(
1110                "upload-pack requested missing object {want}"
1111            )));
1112        }
1113    }
1114    let known_haves = decoded_negotiation
1115        .haves
1116        .into_iter()
1117        .filter_map(|oid| match remote_db.contains(&oid) {
1118            Ok(true) => Some(Ok(oid)),
1119            Ok(false) => None,
1120            Err(err) => Some(Err(err)),
1121        })
1122        .collect::<Result<Vec<_>>>()?;
1123    // Trace2 `fetch-info` parity: upstream upload-pack emits a data_json
1124    // event the shallow tests grep for; the in-process server inherits the
1125    // client's GIT_TRACE2_EVENT just like a spawned upload-pack would.
1126    trace2_fetch_info(
1127        known_haves.len(),
1128        decoded_request.wants.len(),
1129        deepen.map(|plan| plan.depth).unwrap_or(0),
1130        deepen.map(|plan| plan.client_shallow.len()).unwrap_or(0),
1131        deepen.is_some_and(|plan| plan.deepen_since),
1132        deepen.map(|plan| plan.deepen_not).unwrap_or(0),
1133        filter.as_ref(),
1134    );
1135    // With a deepen plan the haves walk is cut at the client's existing
1136    // boundary: having a commit inside the old shallow window must not imply
1137    // having the history below it (upstream runs pack-objects with the
1138    // client's shallow file for exactly this reason).
1139    let mut excluded = match deepen {
1140        Some(plan) => {
1141            let cut: HashSet<ObjectId> = plan.client_shallow.iter().copied().collect();
1142            sley_odb::collect_reachable_object_ids_with_cut(&remote_db, format, known_haves, &cut)?
1143        }
1144        None => {
1145            // The negotiated haves describe the client's object graph. A local
1146            // remote may be intentionally incomplete while the client has the
1147            // missing bases already, so walk the exclusion closure locally and
1148            // keep the actual pack source pinned to the remote below.
1149            sley_odb::collect_reachable_object_ids_tolerating_promised_missing(
1150                &local_db,
1151                format,
1152                known_haves,
1153            )?
1154        }
1155    };
1156    let mut starts = decoded_request.wants;
1157    let promisor_ref_wants = starts.iter().copied().collect::<HashSet<_>>();
1158    for want in &starts {
1159        excluded.remove(want);
1160    }
1161    if let Some(plan) = deepen {
1162        // Stop the pack walk at the shallow boundary and pack the history a
1163        // moved boundary newly exposes.
1164        excluded.extend(plan.excluded.iter().copied());
1165        starts.extend(plan.extra_wants.iter().copied());
1166    }
1167    let install = build_and_install_reachable_pack_filtered(
1168        &remote_db,
1169        &local_db,
1170        format,
1171        starts,
1172        &excluded,
1173        RawPackInstallOptions { promisor },
1174        filter.clone(),
1175        unpack_limit,
1176    )?;
1177    if promisor
1178        && record_promisor_refs
1179        && let Some(result) = install
1180        && let Some(promisor_path) = result.promisor_path
1181    {
1182        append_promisor_ref_lines(&promisor_path, remote_git_dir, format, &promisor_ref_wants)?;
1183    }
1184    Ok(deepen
1185        .map(|plan| plan.shallow_info.clone())
1186        .unwrap_or_default())
1187}
1188
1189fn local_upload_pack_client_wants_v2(git_dir: &Path) -> bool {
1190    sley_config::read_repo_config(git_dir, None)
1191        .ok()
1192        .and_then(|config| config.get("protocol", None, "version").map(str::to_string))
1193        .as_deref()
1194        == Some("2")
1195}
1196
1197fn repo_has_promisor_remote(git_dir: &Path) -> bool {
1198    let Ok(config) = sley_config::read_repo_config(git_dir, None) else {
1199        return false;
1200    };
1201    if config
1202        .get("extensions", None, "partialclone")
1203        .is_some_and(|value| !value.is_empty())
1204    {
1205        return true;
1206    }
1207    config.sections.iter().any(|section| {
1208        section.name.eq_ignore_ascii_case("remote")
1209            && section
1210                .subsection
1211                .as_deref()
1212                .is_some_and(|name| config.get_bool("remote", Some(name), "promisor") == Some(true))
1213    })
1214}
1215
1216fn trace_local_upload_pack_v2_capabilities(remote_git_dir: &Path, format: ObjectFormat) {
1217    sley_protocol::set_packet_trace_identity("fetch");
1218    let config = sley_config::read_repo_config(remote_git_dir, None).unwrap_or_default();
1219    sley_protocol::trace_packet_read_payload(b"version 2\n");
1220    sley_protocol::trace_packet_read_payload(
1221        format!("agent={UPSTREAM_GIT_COMPAT_VERSION}\n").as_bytes(),
1222    );
1223    sley_protocol::trace_packet_read_payload(b"ls-refs=unborn\n");
1224    let mut fetch = "fetch=shallow wait-for-done".to_string();
1225    if config
1226        .get_bool("uploadpack", None, "allowfilter")
1227        .unwrap_or(false)
1228    {
1229        fetch.push_str(" filter");
1230    }
1231    if config
1232        .get_bool("uploadpack", None, "allowrefinwant")
1233        .unwrap_or(false)
1234    {
1235        fetch.push_str(" ref-in-want");
1236    }
1237    fetch.push('\n');
1238    sley_protocol::trace_packet_read_payload(fetch.as_bytes());
1239    sley_protocol::trace_packet_read_payload(
1240        format!("object-format={}\n", format.name()).as_bytes(),
1241    );
1242    sley_protocol::trace_packet_read_payload(b"0000");
1243}
1244
1245fn local_upload_pack_filter_protocol_spec(filter: &sley_odb::PackObjectFilter) -> Option<String> {
1246    match filter {
1247        sley_odb::PackObjectFilter::BlobNone => Some("blob:none".to_string()),
1248        sley_odb::PackObjectFilter::BlobLimit(limit) => Some(format!("blob:limit={limit}")),
1249        sley_odb::PackObjectFilter::TreeDepth(depth) => Some(format!("tree:{depth}")),
1250        sley_odb::PackObjectFilter::SparsePathSet(_) => None,
1251    }
1252}
1253
1254fn append_promisor_ref_lines(
1255    promisor_path: &Path,
1256    remote_git_dir: &Path,
1257    format: ObjectFormat,
1258    wanted: &HashSet<ObjectId>,
1259) -> Result<()> {
1260    if wanted.is_empty() {
1261        return Ok(());
1262    }
1263    let store = FileRefStore::new(remote_git_dir, format);
1264    let mut lines = Vec::new();
1265    if let Some(head_target) = store.read_ref("HEAD")? {
1266        let head = Ref {
1267            name: "HEAD".into(),
1268            target: head_target,
1269        };
1270        if let Some((oid, _)) = resolve_for_each_ref_target(&store, &head)?
1271            && wanted.contains(&oid)
1272        {
1273            lines.push(format!("{oid} HEAD\n"));
1274        }
1275    }
1276    for reference in store.list_refs()? {
1277        let Some((oid, _)) = resolve_for_each_ref_target(&store, &reference)? else {
1278            continue;
1279        };
1280        if wanted.contains(&oid) {
1281            lines.push(format!("{oid} {}\n", reference.name));
1282        }
1283    }
1284    if lines.is_empty() {
1285        return Ok(());
1286    }
1287    lines.sort();
1288    let mut file = fs::OpenOptions::new().append(true).open(promisor_path)?;
1289    use std::io::Write as _;
1290    for line in lines {
1291        file.write_all(line.as_bytes())?;
1292    }
1293    Ok(())
1294}
1295
1296/// Append upstream upload-pack's `fetch-info` data_json event to the file
1297/// named by `GIT_TRACE2_EVENT` (`trace2_fetch_info` in `upload-pack.c`). The
1298/// subset of fields the test suite greps is emitted with upstream spellings.
1299fn trace2_fetch_info(
1300    haves: usize,
1301    wants: usize,
1302    depth: u32,
1303    shallows: usize,
1304    deepen_since: bool,
1305    deepen_not: usize,
1306    filter: Option<&sley_odb::PackObjectFilter>,
1307) {
1308    let Some(path) = std::env::var_os("GIT_TRACE2_EVENT") else {
1309        return;
1310    };
1311    if path.is_empty() {
1312        return;
1313    }
1314    let filter_json = match filter {
1315        Some(sley_odb::PackObjectFilter::BlobNone) => "\"blob:none\"".to_string(),
1316        Some(sley_odb::PackObjectFilter::BlobLimit(limit)) => {
1317            format!("\"blob:limit={limit}\"")
1318        }
1319        Some(sley_odb::PackObjectFilter::TreeDepth(depth)) => {
1320            format!("\"tree:{depth}\"")
1321        }
1322        Some(sley_odb::PackObjectFilter::SparsePathSet(_)) => "\"sparse:oid\"".to_string(),
1323        None => "null".to_string(),
1324    };
1325    let line = format!(
1326        "{{\"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"
1327    );
1328    if let Ok(mut file) = std::fs::OpenOptions::new()
1329        .create(true)
1330        .append(true)
1331        .open(&path)
1332    {
1333        use std::io::Write as _;
1334        let _ = file.write_all(line.as_bytes());
1335    }
1336}
1337
1338// ---------------------------------------------------------------------------
1339// Protocol v2 upload-pack server (`GIT_PROTOCOL=version=2`).
1340//
1341// Mirrors upstream `upload-pack.c::upload_pack_v2` / `serve.c`: advertise the
1342// v2 capabilities, then read `command=ls-refs` / `command=fetch` requests until
1343// EOF, answering each with the protocol-v2 response. The transport (file://
1344// spawned process, git:// daemon child) hands us a connected stdin/stdout pair;
1345// everything below is transport-independent.
1346// ---------------------------------------------------------------------------
1347
1348#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1349enum LsRefsUnbornConfig {
1350    Ignore,
1351    Allow,
1352    Advertise,
1353}
1354
1355fn lsrefs_unborn_config(config: &GitConfig) -> LsRefsUnbornConfig {
1356    match config.get("lsrefs", None, "unborn") {
1357        Some("ignore") => LsRefsUnbornConfig::Ignore,
1358        Some("allow") => LsRefsUnbornConfig::Allow,
1359        Some("advertise") | None => LsRefsUnbornConfig::Advertise,
1360        Some(_) => LsRefsUnbornConfig::Advertise,
1361    }
1362}
1363
1364fn upload_pack_blob_packfile_uri_configured(config: &GitConfig) -> bool {
1365    config
1366        .get_all("uploadpack", None, "blobpackfileuri")
1367        .into_iter()
1368        .any(|value| value.is_some_and(|value| !value.is_empty()))
1369}
1370
1371/// The v2 capabilities advertised by the upload-pack server, in the order git
1372/// emits them: `agent`, `ls-refs[=unborn]`, `fetch=<features>`,
1373/// `server-option`, `object-format=<hash>`.
1374fn upload_pack_v2_capabilities(
1375    format: ObjectFormat,
1376    config: &GitConfig,
1377) -> Result<Vec<Capability>> {
1378    let mut capabilities = vec![
1379        Capability {
1380            name: "agent".into(),
1381            value: Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}")),
1382        },
1383        encode_protocol_v2_ls_refs_capability(&ProtocolV2LsRefsFeatures {
1384            unborn: lsrefs_unborn_config(config) == LsRefsUnbornConfig::Advertise,
1385            unknown: Vec::new(),
1386        })?,
1387        encode_protocol_v2_fetch_capability(&ProtocolV2FetchFeatures {
1388            shallow: true,
1389            wait_for_done: true,
1390            filter: config
1391                .get_bool("uploadpack", None, "allowfilter")
1392                .unwrap_or(false),
1393            ref_in_want: config
1394                .get_bool("uploadpack", None, "allowrefinwant")
1395                .unwrap_or(false),
1396            packfile_uris: upload_pack_blob_packfile_uri_configured(config),
1397            ..ProtocolV2FetchFeatures::default()
1398        })?,
1399        Capability {
1400            name: "server-option".into(),
1401            value: None,
1402        },
1403        Capability {
1404            name: "object-format".into(),
1405            value: Some(format.name().into()),
1406        },
1407    ];
1408    if config
1409        .get_bool("transfer", None, "advertisesid")
1410        .unwrap_or(false)
1411    {
1412        capabilities.push(Capability {
1413            name: "session-id".into(),
1414            value: Some("sley".into()),
1415        });
1416    }
1417    Ok(capabilities)
1418}
1419
1420/// Resolve the symref target of `HEAD` (e.g. `refs/heads/main`) for the
1421/// `symrefs`/symref-target ls-refs attribute, following one level of symbolic
1422/// indirection. Returns `None` for a detached or missing `HEAD`.
1423fn head_symref_target(store: &FileRefStore) -> Result<Option<String>> {
1424    match store.read_ref("HEAD")? {
1425        Some(RefTarget::Symbolic(name)) => Ok(Some(name)),
1426        _ => Ok(None),
1427    }
1428}
1429
1430/// Build the protocol-v2 `ls-refs` records for the repository at `git_dir`,
1431/// honoring the request's `ref-prefix`, `peel`, `symrefs`, and `unborn`
1432/// arguments. Mirrors `ls-refs.c::ls_refs`.
1433fn local_ls_refs_v2_records(
1434    git_dir: &Path,
1435    format: ObjectFormat,
1436    request: &ProtocolV2LsRefsRequest,
1437    config: &GitConfig,
1438) -> Result<Vec<ProtocolV2LsRefsRecord>> {
1439    let store = FileRefStore::new(git_dir, format);
1440    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1441    let head_symref = head_symref_target(&store)?;
1442
1443    // Build the (name -> oid, symref) list in git's advertisement order: HEAD
1444    // first (when present), then the sorted ref list from `for-each-ref`.
1445    let mut entries: Vec<(String, ObjectId, Option<String>)> = Vec::new();
1446    if let Some(target) = store.read_ref("HEAD")? {
1447        let reference = Ref {
1448            name: "HEAD".to_string(),
1449            target,
1450        };
1451        if let Some((oid, _)) = resolve_for_each_ref_target(&store, &reference)? {
1452            entries.push(("HEAD".to_string(), oid, head_symref.clone()));
1453        } else if request.unborn && lsrefs_unborn_config(config) != LsRefsUnbornConfig::Ignore {
1454            // An unborn HEAD (points at a not-yet-created branch) is reported as
1455            // an `unborn` record carrying its symref-target.
1456            entries.push((
1457                "HEAD".to_string(),
1458                ObjectId::null(format),
1459                head_symref.clone(),
1460            ));
1461        }
1462    }
1463    for reference in store.list_refs()? {
1464        let name = reference.name.clone();
1465        let Some((oid, symref)) = resolve_for_each_ref_target(&store, &reference)? else {
1466            continue;
1467        };
1468        entries.push((name, oid, symref));
1469    }
1470
1471    let matches_prefix = |name: &str| -> bool {
1472        if request.ref_prefixes.is_empty() {
1473            return true;
1474        }
1475        request
1476            .ref_prefixes
1477            .iter()
1478            .any(|prefix| name.starts_with(prefix.as_str()))
1479    };
1480
1481    let mut records = Vec::new();
1482    for (name, oid, symref) in entries {
1483        if !matches_prefix(&name) {
1484            continue;
1485        }
1486        // Unborn HEAD: only the all-zero placeholder reaches here with `unborn`.
1487        if name == "HEAD" && oid == ObjectId::null(format) {
1488            records.push(ProtocolV2LsRefsRecord::Unborn {
1489                name,
1490                symref_target: if request.symrefs { symref } else { None },
1491                attributes: Vec::new(),
1492            });
1493            continue;
1494        }
1495        let peeled = if request.peel {
1496            let object = db.read_object(&oid)?;
1497            if object.object_type == ObjectType::Tag {
1498                Some(sley_rev::peel_tags(&db, format, &oid)?)
1499            } else {
1500                None
1501            }
1502        } else {
1503            None
1504        };
1505        let symref_target = if request.symrefs { symref } else { None };
1506        records.push(ProtocolV2LsRefsRecord::Ref(ProtocolV2LsRefsRef {
1507            oid,
1508            name,
1509            peeled,
1510            symref_target,
1511            attributes: Vec::new(),
1512        }));
1513    }
1514    Ok(records)
1515}
1516
1517/// Chunk a raw packfile into sideband channel-1 (`SideBandChannel::Data`)
1518/// pkt-lines for the v2 fetch `packfile` section, matching the upstream
1519/// `0001`-prefixed framing. Each chunk carries at most
1520/// `PKT_LINE_MAX_PAYLOAD_LEN - 1` packfile bytes (the leading byte is the
1521/// channel marker).
1522fn packfile_section_lines(pack: &[u8]) -> Vec<Vec<u8>> {
1523    let chunk = PKT_LINE_MAX_PAYLOAD_LEN - 1;
1524    let mut lines = Vec::new();
1525    for slice in pack.chunks(chunk) {
1526        let mut payload = Vec::with_capacity(slice.len() + 1);
1527        payload.push(1u8); // SideBandChannel::Data
1528        payload.extend_from_slice(slice);
1529        lines.push(payload);
1530    }
1531    lines
1532}
1533
1534/// Build the protocol-v2 `fetch` response sections for a request against the
1535/// repository at `git_dir`. Mirrors `upload-pack.c::upload_pack_v2`'s
1536/// stateless single-round behavior: the client always sends `done` (the v2
1537/// clone/fetch path negotiates haves up front and finishes with `done`), so the
1538/// acknowledgments section is omitted and the response is just the packfile.
1539fn local_fetch_v2_sections(
1540    git_dir: &Path,
1541    format: ObjectFormat,
1542    request: &ProtocolV2FetchRequest,
1543) -> Result<Vec<ProtocolV2FetchResponseSection>> {
1544    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1545
1546    let mut sections = Vec::new();
1547
1548    // Acknowledgments: per gitprotocol-v2, when the client sends `done` the
1549    // acknowledgments section MUST be omitted. Without `done` (multi-round
1550    // negotiation) we answer NAK/ACK for the haves we have in common; the v2
1551    // file:// client always finishes with `done` so this branch is the
1552    // negotiation fallback.
1553    if !request.done {
1554        let mut acks: Vec<ProtocolV2FetchAcknowledgment> = Vec::new();
1555        for have in &request.haves {
1556            if db.contains(have)? {
1557                acks.push(ProtocolV2FetchAcknowledgment::Ack(*have));
1558            }
1559        }
1560        if acks.is_empty() {
1561            acks.push(ProtocolV2FetchAcknowledgment::Nak);
1562        }
1563        sections.push(ProtocolV2FetchResponseSection::Acknowledgments(acks));
1564        // Without `done` and no `ready`, the server stops here to let the
1565        // client continue negotiating; it would re-issue fetch with `done`.
1566        if !request.wait_for_done {
1567            return Ok(sections);
1568        }
1569    }
1570
1571    // Wanted-refs: resolve each `want-ref <name>` to its current oid.
1572    if !request.want_refs.is_empty() {
1573        let store = FileRefStore::new(git_dir, format);
1574        let mut wanted = Vec::new();
1575        for name in &request.want_refs {
1576            let reference = Ref {
1577                name: name.clone(),
1578                target: store
1579                    .read_ref(name)?
1580                    .ok_or_else(|| GitError::not_found(format!("want-ref {name}")))?,
1581            };
1582            let (oid, _) = resolve_for_each_ref_target(&store, &reference)?
1583                .ok_or_else(|| GitError::not_found(format!("want-ref {name}")))?;
1584            wanted.push(sley_protocol::ProtocolV2FetchWantedRef {
1585                oid,
1586                name: name.clone(),
1587            });
1588        }
1589        sections.push(ProtocolV2FetchResponseSection::WantedRefs(wanted));
1590    }
1591
1592    // Resolve want-refs into concrete wants for the pack walk.
1593    let mut wants: Vec<ObjectId> = request.wants.clone();
1594    if !request.want_refs.is_empty()
1595        && let Some(ProtocolV2FetchResponseSection::WantedRefs(wanted)) = sections
1596            .iter()
1597            .find(|s| matches!(s, ProtocolV2FetchResponseSection::WantedRefs(_)))
1598    {
1599        for w in wanted {
1600            wants.push(w.oid);
1601        }
1602    }
1603
1604    // Packfile section: build the reachable pack excluding the client's haves.
1605    let mut known_haves: Vec<ObjectId> = Vec::new();
1606    for have in &request.haves {
1607        if db.contains(have)? {
1608            known_haves.push(*have);
1609        }
1610    }
1611    let excluded = collect_reachable_object_ids(&db, format, known_haves)?;
1612    let pack = build_reachable_pack(&db, format, wants, &excluded)?
1613        .map(|pack| pack.pack)
1614        .unwrap_or_default();
1615
1616    sections.push(ProtocolV2FetchResponseSection::Packfile(
1617        packfile_section_lines(&pack),
1618    ));
1619    Ok(sections)
1620}
1621
1622/// Serve a protocol-v2 upload-pack session over `reader`/`writer` for the
1623/// repository at `git_dir`. Writes the capability advertisement, then loops
1624/// reading `command=` requests (`ls-refs` / `fetch`) until the client closes
1625/// the connection (EOF). Mirrors `upload-pack.c::upload_pack_v2` driven by
1626/// `serve.c`.
1627pub fn serve_upload_pack_v2(
1628    git_dir: &Path,
1629    format: ObjectFormat,
1630    reader: &mut impl std::io::Read,
1631    writer: &mut impl std::io::Write,
1632) -> Result<()> {
1633    let config = sley_config::read_repo_config(git_dir, None).unwrap_or_default();
1634    serve_upload_pack_v2_with_config(git_dir, format, &config, reader, writer)
1635}
1636
1637pub fn serve_upload_pack_v2_with_config(
1638    git_dir: &Path,
1639    format: ObjectFormat,
1640    config: &GitConfig,
1641    reader: &mut impl std::io::Read,
1642    writer: &mut impl std::io::Write,
1643) -> Result<()> {
1644    let handshake = TransportHandshake {
1645        protocol: ProtocolVersion::V2,
1646        capabilities: upload_pack_v2_capabilities(format, config)?,
1647    };
1648    write_protocol_v2_advertisement(writer, &handshake)?;
1649    writer.flush()?;
1650
1651    // EOF / a lone flush after the advertisement ends the session: the client
1652    // disconnected (e.g. `ls-remote` reads the refs and leaves). Malformed
1653    // requests after a command line are protocol violations and must fail
1654    // visibly instead of being treated as a clean disconnect.
1655    loop {
1656        let request = match read_protocol_v2_command_request(reader) {
1657            Ok(request) => request,
1658            Err(GitError::InvalidFormat(message))
1659                if message == "pkt-line stream ended before control packet"
1660                    || message == "protocol v2 command request must start with a command line" =>
1661            {
1662                break;
1663            }
1664            Err(err) => return Err(err),
1665        };
1666        match classify_protocol_v2_command_request(&handshake, format, &request)? {
1667            sley_protocol::ProtocolV2Command::LsRefs(ls_refs) => {
1668                let records = local_ls_refs_v2_records(git_dir, format, &ls_refs, config)?;
1669                write_protocol_v2_ls_refs_response(writer, &records)?;
1670                writer.flush()?;
1671            }
1672            sley_protocol::ProtocolV2Command::Fetch(fetch) => {
1673                let sections = local_fetch_v2_sections(git_dir, format, &fetch)?;
1674                write_protocol_v2_fetch_response(writer, &sections)?;
1675                writer.flush()?;
1676            }
1677            sley_protocol::ProtocolV2Command::ObjectInfo(_)
1678            | sley_protocol::ProtocolV2Command::Unknown(_) => {
1679                return Err(GitError::InvalidFormat(format!(
1680                    "unsupported protocol v2 command {}",
1681                    request.command
1682                )));
1683            }
1684        }
1685    }
1686    Ok(())
1687}
1688
1689#[cfg(test)]
1690mod tests {
1691    use super::*;
1692    use sley_object::{BString, EncodedObject, Tree, TreeEntry};
1693    use sley_odb::ObjectWriter;
1694
1695    #[test]
1696    fn receive_pack_advertises_no_thin_until_server_fixes_thin_packs() {
1697        let features = receive_pack_features(ObjectFormat::Sha1);
1698        assert!(features.no_thin);
1699
1700        let capabilities =
1701            encode_receive_pack_features(&features).expect("test operation should succeed");
1702        assert!(
1703            capabilities
1704                .iter()
1705                .any(|capability| capability.name == "no-thin")
1706        );
1707    }
1708
1709    #[test]
1710    fn local_fetch_from_incomplete_remote_excludes_client_have_closure() {
1711        let root = unique_local_test_dir("incomplete-local-fetch");
1712        let base_git = root.join("base.git");
1713        let patch_git = root.join("patch.git");
1714        let user_git = root.join("user.git");
1715        let direct_git = root.join("direct.git");
1716        for git_dir in [&base_git, &patch_git, &user_git, &direct_git] {
1717            fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
1718            fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
1719                .expect("test operation should succeed");
1720        }
1721
1722        let format = ObjectFormat::Sha1;
1723        let base_db = FileObjectDatabase::from_git_dir(&base_git, format);
1724        let patch_db = FileObjectDatabase::from_git_dir(&patch_git, format);
1725
1726        let text_a = EncodedObject::new(ObjectType::Blob, b"a\nb\nc\nd\ne\nf\ng\nh\ni\n".to_vec());
1727        let text_a_oid = write_test_object(&base_db, &text_a);
1728        let side = EncodedObject::new(ObjectType::Blob, b"side\n".to_vec());
1729        let side_oid = write_test_object(&base_db, &side);
1730        let tree_a = test_tree(&[
1731            (0o100644, b"side", side_oid),
1732            (0o100644, b"text", text_a_oid),
1733        ]);
1734        let tree_a_oid = write_test_object(&base_db, &tree_a);
1735        let commit_a = test_commit(tree_a_oid, &[], b"A\n");
1736        let commit_a_oid = write_test_object(&base_db, &commit_a);
1737
1738        let text_b =
1739            EncodedObject::new(ObjectType::Blob, b"a\nb\nc\nd\ne\nf\ng\nh\ni\nm\n".to_vec());
1740        let text_b_oid = write_test_object(&base_db, &text_b);
1741        let tree_b = test_tree(&[
1742            (0o100644, b"side", side_oid),
1743            (0o100644, b"text", text_b_oid),
1744        ]);
1745        let tree_b_oid = write_test_object(&base_db, &tree_b);
1746        let commit_b = test_commit(tree_b_oid, &[commit_a_oid], b"B\n");
1747        let commit_b_oid = write_test_object(&base_db, &commit_b);
1748
1749        let text_c = EncodedObject::new(
1750            ObjectType::Blob,
1751            b"a\nb\nc\nd\ne\nf\ng\nh\ni\nm\nq\n".to_vec(),
1752        );
1753        let text_c_oid = write_test_object(&patch_db, &text_c);
1754        let tree_c = test_tree(&[
1755            (0o100644, b"side", side_oid),
1756            (0o100644, b"text", text_c_oid),
1757        ]);
1758        let tree_c_oid = write_test_object(&patch_db, &tree_c);
1759        let commit_c = test_commit(tree_c_oid, &[commit_b_oid], b"C\n");
1760        let commit_c_oid = write_test_object(&patch_db, &commit_c);
1761        write_test_object(&patch_db, &tree_b);
1762        write_test_object(&patch_db, &commit_b);
1763        assert!(
1764            !patch_db
1765                .contains(&text_b_oid)
1766                .expect("test operation should succeed"),
1767            "patch repo must be missing the best delta base"
1768        );
1769
1770        install_fetch_pack_via_local_upload_pack(
1771            &user_git,
1772            &base_git,
1773            format,
1774            vec![commit_b_oid],
1775            None,
1776            false,
1777            false,
1778            None,
1779            false,
1780            None,
1781        )
1782        .expect("base fetch should succeed");
1783        assert!(
1784            FileObjectDatabase::from_git_dir(&user_git, format)
1785                .contains(&text_b_oid)
1786                .expect("test operation should succeed"),
1787            "user clone should have the missing base before fetching C"
1788        );
1789
1790        install_fetch_pack_via_local_upload_pack(
1791            &user_git,
1792            &patch_git,
1793            format,
1794            vec![commit_c_oid],
1795            None,
1796            false,
1797            false,
1798            None,
1799            false,
1800            None,
1801        )
1802        .expect("fetch from incomplete remote should succeed when client has the base");
1803        assert!(
1804            FileObjectDatabase::from_git_dir(&user_git, format)
1805                .contains(&commit_c_oid)
1806                .expect("test operation should succeed")
1807        );
1808
1809        let direct = install_fetch_pack_via_local_upload_pack(
1810            &direct_git,
1811            &patch_git,
1812            format,
1813            vec![commit_c_oid],
1814            None,
1815            false,
1816            false,
1817            None,
1818            false,
1819            None,
1820        );
1821        assert!(
1822            direct.is_err(),
1823            "direct fetch from the incomplete patch repo must still fail"
1824        );
1825
1826        fs::remove_dir_all(root).expect("test operation should succeed");
1827    }
1828
1829    #[test]
1830    fn direct_promisor_object_fetch_does_not_walk_missing_local_blobs() {
1831        let root = unique_local_test_dir("direct-promisor-object-fetch");
1832        let remote_git = root.join("remote.git");
1833        let client_git = root.join("client.git");
1834        for git_dir in [&remote_git, &client_git] {
1835            fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
1836            fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
1837                .expect("test operation should succeed");
1838        }
1839
1840        let format = ObjectFormat::Sha1;
1841        let remote_db = FileObjectDatabase::from_git_dir(&remote_git, format);
1842        let client_db = FileObjectDatabase::from_git_dir(&client_git, format);
1843
1844        let blob = EncodedObject::new(ObjectType::Blob, b"promised\n".to_vec());
1845        let blob_oid = write_test_object(&remote_db, &blob);
1846        let tree = test_tree(&[(0o100644, b"file.txt", blob_oid)]);
1847        let tree_oid = write_test_object(&remote_db, &tree);
1848        let commit = test_commit(tree_oid, &[], b"main\n");
1849        let commit_oid = write_test_object(&remote_db, &commit);
1850
1851        write_test_object(&client_db, &tree);
1852        write_test_object(&client_db, &commit);
1853        assert!(
1854            !client_db
1855                .contains(&blob_oid)
1856                .expect("test operation should succeed"),
1857            "client starts with the promised blob missing"
1858        );
1859
1860        install_fetch_pack_via_local_upload_pack(
1861            &client_git,
1862            &remote_git,
1863            format,
1864            vec![blob_oid],
1865            None,
1866            true,
1867            false,
1868            None,
1869            false,
1870            None,
1871        )
1872        .expect("direct promisor blob fetch should not traverse missing local blobs");
1873
1874        assert!(
1875            FileObjectDatabase::from_git_dir(&client_git, format)
1876                .contains(&blob_oid)
1877                .expect("test operation should succeed"),
1878            "directly wanted promised blob should be installed"
1879        );
1880        assert_eq!(
1881            FileObjectDatabase::from_git_dir(&client_git, format)
1882                .read_object(&commit_oid)
1883                .expect("test operation should succeed")
1884                .object_type,
1885            ObjectType::Commit
1886        );
1887
1888        fs::remove_dir_all(root).expect("test operation should succeed");
1889    }
1890
1891    fn unique_local_test_dir(name: &str) -> std::path::PathBuf {
1892        let nanos = SystemTime::now()
1893            .duration_since(UNIX_EPOCH)
1894            .expect("test operation should succeed")
1895            .as_nanos();
1896        let root =
1897            std::env::temp_dir().join(format!("sley-remote-{name}-{}-{nanos}", std::process::id()));
1898        fs::create_dir_all(&root).expect("test operation should succeed");
1899        root
1900    }
1901
1902    fn write_test_object(db: &FileObjectDatabase, object: &EncodedObject) -> ObjectId {
1903        db.write_object(object.clone())
1904            .expect("test operation should succeed")
1905    }
1906
1907    fn test_tree(entries: &[(u32, &[u8], ObjectId)]) -> EncodedObject {
1908        EncodedObject::new(
1909            ObjectType::Tree,
1910            Tree {
1911                entries: entries
1912                    .iter()
1913                    .map(|(mode, name, oid)| TreeEntry {
1914                        mode: *mode,
1915                        name: BString::from(*name),
1916                        oid: *oid,
1917                    })
1918                    .collect(),
1919            }
1920            .write(),
1921        )
1922    }
1923
1924    fn test_commit(tree: ObjectId, parents: &[ObjectId], message: &[u8]) -> EncodedObject {
1925        let identity = b"Example <example@example.invalid> 0 +0000".to_vec();
1926        EncodedObject::new(
1927            ObjectType::Commit,
1928            Commit {
1929                tree,
1930                parents: parents.to_vec(),
1931                author: identity.clone(),
1932                committer: identity,
1933                encoding: None,
1934                message: message.to_vec(),
1935            }
1936            .write(),
1937        )
1938    }
1939}