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::{BinaryHeap, 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, ReachablePackMissingPolicy,
25    ReachablePackThinBaseCandidates, build_and_install_reachable_pack,
26    build_and_install_reachable_pack_filtered_with_thin_bases, build_reachable_pack,
27    build_reachable_pack_filtered, collect_reachable_object_ids,
28};
29use sley_protocol::{
30    PKT_LINE_MAX_PAYLOAD_LEN, PktLineFrame, ProtocolV2FetchAcknowledgment, ProtocolV2FetchFeatures,
31    ProtocolV2FetchRequest, ProtocolV2FetchResponseSection, ProtocolV2FetchShallowInfo,
32    ProtocolV2FetchWantedRef, ProtocolV2LsRefsFeatures, ProtocolV2LsRefsRecord,
33    ProtocolV2LsRefsRef, ProtocolV2LsRefsRequest, ProtocolVersion, ReceivePackCommand,
34    ReceivePackCommandStatus, ReceivePackFeatures, ReceivePackPushRequest,
35    ReceivePackPushRequestHeader, ReceivePackReportStatus, ReceivePackRequest,
36    ReceivePackUnpackStatus, RefAdvertisement, SideBandChannel, SideBandPacket, TransportHandshake,
37    UploadPackAckStatus, UploadPackAcknowledgment, UploadPackFeatures,
38    UploadPackNegotiationRequest, UploadPackPackfileResponse, UploadPackRawPackfileResponse,
39    UploadPackRequest, apply_receive_pack_push_request, build_upload_pack_raw_packfile_response,
40    classify_protocol_v2_command_request, encode_protocol_v2_fetch_capability,
41    encode_protocol_v2_ls_refs_capability, encode_receive_pack_features,
42    encode_upload_pack_features, read_protocol_v2_command_request, read_upload_pack_acknowledgment,
43    read_upload_pack_negotiation_request, read_upload_pack_request,
44    validate_receive_pack_push_request_features, write_pkt_line_frame, write_pkt_line_payload,
45    write_protocol_v2_advertisement, write_protocol_v2_fetch_request,
46    write_protocol_v2_fetch_response, write_protocol_v2_ls_refs_response,
47    write_upload_pack_acknowledgment, write_upload_pack_negotiation_request,
48    write_upload_pack_request,
49};
50use sley_refs::{
51    DeleteRef, FileRefStore, Ref, RefDeletePrecondition, RefPrecondition, RefTarget, ReflogEntry,
52};
53
54use crate::install::install_protocol_v2_fetch_response_from_reader;
55
56/// The all-zero object id for `format`, used for the synthetic
57/// `capabilities^{}` advertisement when a repository has no refs.
58fn zero_oid(format: ObjectFormat) -> Result<ObjectId> {
59    Ok(ObjectId::null(format))
60}
61
62/// Resolve a (possibly symbolic) ref target to its object id, following up to
63/// five levels of symbolic indirection, returning the first symbolic name seen.
64fn resolve_for_each_ref_target(
65    store: &FileRefStore,
66    reference: &Ref,
67) -> Result<Option<(ObjectId, Option<String>)>> {
68    let mut target = reference.target.clone();
69    let mut symref = None;
70    for _ in 0..5 {
71        match target {
72            RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
73            RefTarget::Symbolic(name) => {
74                symref.get_or_insert_with(|| name.clone());
75                let Some(next) = store.read_ref(&name)? else {
76                    return Ok(None);
77                };
78                target = next;
79            }
80        }
81    }
82    Ok(None)
83}
84
85/// The upload-pack capabilities advertised for the repository at `git_dir`:
86/// the object format, side-band-64k, and a `HEAD` symref hint if present.
87pub fn upload_pack_features(git_dir: &Path, format: ObjectFormat) -> Result<UploadPackFeatures> {
88    let store = FileRefStore::new(git_dir, format);
89    let mut symrefs = Vec::new();
90    if let Some(RefTarget::Symbolic(target)) = store.read_ref("HEAD")? {
91        symrefs.push(format!("HEAD:{target}"));
92    }
93    Ok(UploadPackFeatures {
94        object_format: Some(format),
95        side_band_64k: true,
96        symrefs,
97        ..UploadPackFeatures::default()
98    })
99}
100
101/// Whether the client negotiated a side-band channel for the packfile response.
102pub fn upload_pack_request_uses_sideband(request: &UploadPackRequest) -> bool {
103    request
104        .capabilities
105        .iter()
106        .any(|capability| matches!(capability.name.as_str(), "side-band" | "side-band-64k"))
107}
108
109/// Re-frame a raw packfile response as side-band data packets, chunked to the
110/// pkt-line payload limit (less the one-byte channel prefix).
111pub fn upload_pack_sideband_response(
112    response: UploadPackRawPackfileResponse,
113) -> UploadPackPackfileResponse {
114    let mut sideband = Vec::new();
115    let chunk_len = PKT_LINE_MAX_PAYLOAD_LEN - 1;
116    for chunk in response.packfile.chunks(chunk_len) {
117        sideband.push(SideBandPacket {
118            channel: SideBandChannel::Data,
119            data: chunk.to_vec(),
120        });
121    }
122    UploadPackPackfileResponse {
123        acknowledgments: response.acknowledgments,
124        sideband,
125    }
126}
127
128/// Encode `features` into the leading ref advertisement's capability list,
129/// inserting a synthetic `capabilities^{}` entry when there are no refs.
130pub fn attach_upload_pack_capabilities(
131    advertisements: &mut Vec<RefAdvertisement>,
132    format: ObjectFormat,
133    features: &UploadPackFeatures,
134) -> Result<()> {
135    let capabilities = encode_upload_pack_features(features)?;
136    if let Some(first) = advertisements.first_mut() {
137        first.capabilities = capabilities;
138    } else {
139        advertisements.push(RefAdvertisement {
140            oid: zero_oid(format)?,
141            name: "capabilities^{}".into(),
142            capabilities,
143        });
144    }
145    Ok(())
146}
147
148/// Serve an upload-pack request from the repository at `git_dir`: build the
149/// packfile that carries every reachable object the client `wants` but does not
150/// already `haves`, framed as a raw (non-side-band) response.
151pub fn upload_pack_from_local_repository(
152    git_dir: &Path,
153    format: ObjectFormat,
154    features: &UploadPackFeatures,
155    request: UploadPackRequest,
156    haves: HashSet<ObjectId>,
157) -> Result<UploadPackRawPackfileResponse> {
158    let db = FileObjectDatabase::from_git_dir(git_dir, format);
159    build_upload_pack_raw_packfile_response(
160        features,
161        request,
162        haves,
163        |oid| db.contains(oid),
164        |wants, known_haves| {
165            let excluded = collect_reachable_object_ids(&db, format, known_haves)?;
166            build_reachable_pack(&db, format, wants, &excluded)
167                .map(|pack| pack.map(|pack| pack.pack))
168        },
169    )
170}
171
172/// The receive-pack capabilities advertised for a local repository: report
173/// status, ref deletion, ofs-delta, push-options, quiet, no-thin, and the object
174/// format.
175pub fn receive_pack_features(format: ObjectFormat) -> ReceivePackFeatures {
176    ReceivePackFeatures {
177        report_status: true,
178        report_status_v2: true,
179        delete_refs: true,
180        ofs_delta: true,
181        push_options: true,
182        quiet: true,
183        no_thin: true,
184        atomic: true,
185        side_band_64k: true,
186        object_format: Some(format),
187        ..ReceivePackFeatures::default()
188    }
189}
190
191/// Whether the client negotiated `push-options` (so the caller must read the
192/// push-option section that follows the command list).
193pub fn receive_pack_request_uses_push_options(request: &ReceivePackRequest) -> bool {
194    request
195        .capabilities
196        .iter()
197        .any(|capability| capability.name == "push-options")
198}
199
200/// Encode `features` into the leading ref advertisement's capability list,
201/// inserting a synthetic `capabilities^{}` entry when there are no refs.
202pub fn attach_receive_pack_capabilities(
203    advertisements: &mut Vec<RefAdvertisement>,
204    format: ObjectFormat,
205    features: &ReceivePackFeatures,
206) -> Result<()> {
207    let capabilities = encode_receive_pack_features(features)?;
208    if let Some(first) = advertisements.first_mut() {
209        first.capabilities = capabilities;
210    } else {
211        advertisements.push(RefAdvertisement {
212            oid: zero_oid(format)?,
213            name: "capabilities^{}".into(),
214            capabilities,
215        });
216    }
217    Ok(())
218}
219
220/// Apply a receive-pack push to the repository at `remote_git_dir`: install the
221/// incoming packfile and execute the ref creations/updates/deletions, returning
222/// the report-status describing what happened.
223pub fn receive_pack_into_local_repository(
224    remote_git_dir: &Path,
225    format: ObjectFormat,
226    request: &ReceivePackPushRequest,
227) -> Result<ReceivePackReportStatus> {
228    let remote_store = FileRefStore::new(remote_git_dir, format);
229    let remote_db = FileObjectDatabase::from_git_dir(remote_git_dir, format);
230    let deletes_applied_with_updates = std::cell::RefCell::new(HashSet::<String>::new());
231    apply_receive_pack_push_request(
232        &receive_pack_features(format),
233        request,
234        |name| match remote_store.read_ref(name)? {
235            Some(RefTarget::Direct(oid)) => Ok(Some(oid)),
236            Some(RefTarget::Symbolic(_)) | None => Ok(None),
237        },
238        |packfile| {
239            let mut reader = packfile;
240            remote_db
241                .install_raw_pack_from_reader(&mut reader)
242                .map(|_| ())
243        },
244        |oid| remote_db.contains(oid),
245        |commands| {
246            let applied = apply_receive_pack_ref_transaction(
247                remote_git_dir,
248                format,
249                &remote_store,
250                commands,
251                &request.commands.commands,
252            )?;
253            deletes_applied_with_updates.borrow_mut().extend(applied);
254            Ok(())
255        },
256        |command| {
257            if deletes_applied_with_updates
258                .borrow()
259                .contains(command.name.as_str())
260            {
261                return Ok(());
262            }
263            remote_store
264                .delete_ref_checked(DeleteRef {
265                    name: command.name.clone(),
266                    expected_old: (!command.old_id.is_null()).then_some(command.old_id),
267                    reflog: None,
268                })
269                .map(|_| ())
270                .map_err(|err| GitError::Transaction(err.to_string()))
271        },
272    )
273}
274
275/// Apply a receive-pack push while streaming the optional incoming packfile from
276/// `pack_reader` into the object database. This mirrors
277/// [`receive_pack_into_local_repository`] but avoids materializing the pack as a
278/// `Vec<u8>` in stdio/SSH server paths.
279pub fn receive_pack_stream_into_local_repository<R: Read>(
280    remote_git_dir: &Path,
281    format: ObjectFormat,
282    header: &ReceivePackPushRequestHeader,
283    pack_reader: &mut R,
284) -> Result<ReceivePackReportStatus> {
285    let remote_store = FileRefStore::new(remote_git_dir, format);
286    let remote_db = FileObjectDatabase::from_git_dir(remote_git_dir, format);
287    let pack_prefix = read_optional_pack_prefix(pack_reader)?;
288    let validation_request = ReceivePackPushRequest {
289        commands: header.commands.clone(),
290        push_options: header.push_options.clone(),
291        packfile: pack_prefix.clone().unwrap_or_default(),
292    };
293    validate_receive_pack_push_request_features(
294        &receive_pack_features(format),
295        &validation_request,
296    )?;
297
298    let deletes_applied_with_updates = std::cell::RefCell::new(HashSet::<String>::new());
299    for command in header
300        .commands
301        .commands
302        .iter()
303        .filter(|command| command.new_id.is_null())
304    {
305        let current = match remote_store.read_ref(&command.name)? {
306            Some(RefTarget::Direct(oid)) => Some(oid),
307            Some(RefTarget::Symbolic(_)) | None => None,
308        };
309        if !command.old_id.is_null() && current != Some(command.old_id.clone()) {
310            return Err(GitError::Transaction(format!(
311                "expected ref {} to match",
312                command.name
313            )));
314        }
315    }
316
317    let updates = header
318        .commands
319        .commands
320        .iter()
321        .filter(|command| !command.new_id.is_null())
322        .cloned()
323        .collect::<Vec<_>>();
324    if !updates.is_empty() {
325        if let Some(prefix) = pack_prefix {
326            let mut stream = Cursor::new(prefix).chain(pack_reader);
327            remote_db
328                .install_raw_pack_from_reader(&mut stream)
329                .map(|_| ())?;
330        }
331        for command in &updates {
332            if !remote_db.contains(&command.new_id)? {
333                return Err(GitError::InvalidObject(format!(
334                    "receive-pack packfile did not provide {}",
335                    command.new_id
336                )));
337            }
338        }
339        let applied = apply_receive_pack_ref_transaction(
340            remote_git_dir,
341            format,
342            &remote_store,
343            &updates,
344            &header.commands.commands,
345        )?;
346        deletes_applied_with_updates.borrow_mut().extend(applied);
347    }
348
349    for command in header
350        .commands
351        .commands
352        .iter()
353        .filter(|command| command.new_id.is_null())
354    {
355        if deletes_applied_with_updates
356            .borrow()
357            .contains(command.name.as_str())
358        {
359            continue;
360        }
361        remote_store
362            .delete_ref_checked(DeleteRef {
363                name: command.name.clone(),
364                expected_old: (!command.old_id.is_null()).then_some(command.old_id),
365                reflog: None,
366            })
367            .map(|_| ())
368            .map_err(|err| GitError::Transaction(err.to_string()))?;
369    }
370
371    Ok(ReceivePackReportStatus {
372        unpack: ReceivePackUnpackStatus::Ok,
373        commands: header
374            .commands
375            .commands
376            .iter()
377            .map(|command| ReceivePackCommandStatus::Ok {
378                name: command.name.clone(),
379            })
380            .collect(),
381    })
382}
383
384fn read_optional_pack_prefix(reader: &mut impl Read) -> Result<Option<Vec<u8>>> {
385    let mut prefix = [0u8; 4];
386    loop {
387        match reader.read(&mut prefix[..1]) {
388            Ok(0) => return Ok(None),
389            Ok(1) => break,
390            Ok(_) => unreachable!("one-byte read returned more than one byte"),
391            Err(err) if err.kind() == ErrorKind::Interrupted => {}
392            Err(err) => return Err(err.into()),
393        }
394    }
395    reader.read_exact(&mut prefix[1..])?;
396    if &prefix != b"PACK" {
397        return Err(GitError::InvalidFormat(
398            "receive-pack packfile must start with PACK".into(),
399        ));
400    }
401    Ok(Some(prefix.to_vec()))
402}
403
404fn receive_pack_log_all_ref_updates(git_dir: &Path) -> bool {
405    let Ok(config) = fs::read_to_string(git_dir.join("config")) else {
406        return false;
407    };
408    let mut in_core = false;
409    for raw_line in config.lines() {
410        let line = raw_line.trim();
411        if line.starts_with('[') && line.ends_with(']') {
412            in_core = line.eq_ignore_ascii_case("[core]");
413            continue;
414        }
415        if !in_core || line.starts_with('#') || line.starts_with(';') {
416            continue;
417        }
418        let Some((name, value)) = line.split_once('=') else {
419            continue;
420        };
421        if name.trim().eq_ignore_ascii_case("logallrefupdates") {
422            return matches!(
423                value.trim().trim_matches('"').to_ascii_lowercase().as_str(),
424                "true" | "yes" | "on" | "1" | "always"
425            );
426        }
427    }
428    false
429}
430
431fn receive_pack_should_write_reflog(refname: &str) -> bool {
432    refname == "HEAD"
433        || refname.starts_with("refs/heads/")
434        || refname.starts_with("refs/remotes/")
435        || refname.starts_with("refs/notes/")
436}
437
438fn receive_pack_reflog_entry(
439    format: ObjectFormat,
440    old_oid: ObjectId,
441    new_oid: ObjectId,
442) -> ReflogEntry {
443    let old_oid = if old_oid.is_null() {
444        ObjectId::null(format)
445    } else {
446        old_oid
447    };
448    ReflogEntry {
449        old_oid,
450        new_oid,
451        committer: receive_pack_reflog_committer(),
452        message: b"push".to_vec(),
453    }
454}
455
456fn receive_pack_reflog_committer() -> Vec<u8> {
457    let seconds = SystemTime::now()
458        .duration_since(UNIX_EPOCH)
459        .map(|duration| duration.as_secs())
460        .unwrap_or(0);
461    format!("Git Rs <sley@example.invalid> {seconds} +0000").into_bytes()
462}
463
464/// Apply a local receive-pack request whose pack can be built from `source_db`
465/// after receive-pack preflight checks pass.
466///
467/// This keeps local push on the same validation path as raw receive-pack while
468/// avoiding a raw-pack round trip: the install closure builds the reachable
469/// pack and installs the generated pack/index directly.
470pub fn receive_pack_reachable_pack_into_local_repository(
471    remote_git_dir: &Path,
472    format: ObjectFormat,
473    request: &ReceivePackPushRequest,
474    source_db: &FileObjectDatabase,
475    starts: Vec<ObjectId>,
476    excluded: HashSet<ObjectId>,
477) -> Result<ReceivePackReportStatus> {
478    let remote_store = FileRefStore::new(remote_git_dir, format);
479    let remote_db = FileObjectDatabase::from_git_dir(remote_git_dir, format);
480    let mut starts = Some(starts);
481    let deletes_applied_with_updates = std::cell::RefCell::new(HashSet::<String>::new());
482    apply_receive_pack_push_request(
483        &receive_pack_features(format),
484        request,
485        |name| match remote_store.read_ref(name)? {
486            Some(RefTarget::Direct(oid)) => Ok(Some(oid)),
487            Some(RefTarget::Symbolic(_)) | None => Ok(None),
488        },
489        |_| {
490            let starts = starts.take().ok_or_else(|| {
491                GitError::InvalidFormat("receive-pack attempted to install pack twice".into())
492            })?;
493            build_and_install_reachable_pack(
494                source_db,
495                &remote_db,
496                format,
497                starts,
498                &excluded,
499                RawPackInstallOptions {
500                    promisor: false,
501                    ..Default::default()
502                },
503            )?;
504            Ok(())
505        },
506        |oid| remote_db.contains(oid),
507        |commands| {
508            let applied = apply_receive_pack_ref_transaction(
509                remote_git_dir,
510                format,
511                &remote_store,
512                commands,
513                &request.commands.commands,
514            )?;
515            deletes_applied_with_updates.borrow_mut().extend(applied);
516            Ok(())
517        },
518        |command| {
519            if deletes_applied_with_updates
520                .borrow()
521                .contains(command.name.as_str())
522            {
523                return Ok(());
524            }
525            remote_store
526                .delete_ref_checked(DeleteRef {
527                    name: command.name.clone(),
528                    expected_old: (!command.old_id.is_null()).then_some(command.old_id),
529                    reflog: None,
530                })
531                .map(|_| ())
532                .map_err(|err| GitError::Transaction(err.to_string()))
533        },
534    )
535}
536
537pub(crate) fn apply_receive_pack_ref_transaction(
538    remote_git_dir: &Path,
539    format: ObjectFormat,
540    store: &FileRefStore,
541    updates: &[ReceivePackCommand],
542    all_commands: &[ReceivePackCommand],
543) -> Result<HashSet<String>> {
544    let updates = canonical_receive_pack_update_commands(store, updates)?;
545    let deletes = all_commands
546        .iter()
547        .filter(|command| command.new_id.is_null())
548        .collect::<Vec<_>>();
549    let mut tx = store.transaction();
550    for command in &deletes {
551        tx.delete_with_precondition(
552            command.name.clone(),
553            RefDeletePrecondition::Direct((!command.old_id.is_null()).then_some(command.old_id)),
554            None,
555        );
556    }
557    let log_updates = receive_pack_log_all_ref_updates(remote_git_dir);
558    for command in &updates {
559        let precondition = if command.old_id.is_null() {
560            RefPrecondition::MustNotExist
561        } else {
562            RefPrecondition::MustExistAndMatch(RefTarget::Direct(command.old_id))
563        };
564        let reflog = if log_updates && receive_pack_should_write_reflog(&command.name) {
565            Some(receive_pack_reflog_entry(
566                format,
567                command.old_id,
568                command.new_id,
569            ))
570        } else {
571            None
572        };
573        tx.update_to(
574            command.name.clone(),
575            RefTarget::Direct(command.new_id),
576            precondition,
577            reflog,
578        );
579    }
580    tx.commit()?;
581    Ok(deletes
582        .into_iter()
583        .map(|command| command.name.clone())
584        .collect())
585}
586
587fn canonical_receive_pack_update_commands(
588    store: &FileRefStore,
589    commands: &[ReceivePackCommand],
590) -> Result<Vec<ReceivePackCommand>> {
591    let mut by_actual = HashMap::<String, ObjectId>::new();
592    let mut canonical = Vec::with_capacity(commands.len());
593    for command in commands {
594        let name = match store.read_ref(&command.name)? {
595            Some(RefTarget::Symbolic(target)) => target,
596            Some(RefTarget::Direct(_)) | None => command.name.clone(),
597        };
598        if let Some(existing) = by_actual.get(&name) {
599            if existing != &command.new_id {
600                return Err(GitError::Command("refusing inconsistent update".into()));
601            }
602        } else {
603            by_actual.insert(name.clone(), command.new_id);
604        }
605        canonical.push(ReceivePackCommand {
606            old_id: command.old_id,
607            new_id: command.new_id,
608            name,
609        });
610    }
611    Ok(canonical)
612}
613
614/// The ref advertisements a local repository would send to a fetching client:
615/// `HEAD` (if resolvable) followed by every ref, each resolved to its object id.
616pub fn local_fetch_advertisements(
617    git_dir: &Path,
618    format: ObjectFormat,
619) -> Result<Vec<RefAdvertisement>> {
620    let store = FileRefStore::new_without_reference_backend_env(git_dir, format);
621    let mut advertisements = Vec::new();
622    if let Some(target) = store.read_ref("HEAD")? {
623        let reference = Ref {
624            name: "HEAD".to_string(),
625            target,
626        };
627        if let Some((oid, _)) = resolve_for_each_ref_target(&store, &reference)? {
628            advertisements.push(RefAdvertisement {
629                oid,
630                name: reference.name,
631                capabilities: Vec::new(),
632            });
633        }
634    }
635    for reference in store.list_refs()? {
636        let Some((oid, _)) = resolve_for_each_ref_target(&store, &reference)? else {
637            continue;
638        };
639        advertisements.push(RefAdvertisement {
640            oid,
641            name: reference.name,
642            capabilities: Vec::new(),
643        });
644    }
645    Ok(advertisements)
646}
647
648/// The object ids the local repository can offer as `have`s during negotiation.
649/// Ref tips are offered first, then every object visible through the local
650/// object database, including alternates recorded in `objects/info/alternates`.
651pub fn local_have_oids(git_dir: &Path, format: ObjectFormat) -> Result<Vec<ObjectId>> {
652    let mut seen = HashSet::new();
653    let mut haves = Vec::new();
654    for advertisement in local_fetch_advertisements(git_dir, format)? {
655        if seen.insert(advertisement.oid) {
656            haves.push(advertisement.oid);
657        }
658    }
659    let db = FileObjectDatabase::from_git_dir(git_dir, format);
660    for oid in db.object_ids()? {
661        if seen.insert(oid) {
662            haves.push(oid);
663        }
664    }
665    Ok(haves)
666}
667
668/// Commit haves ordered from advertised tips toward their ancestors for
669/// protocol negotiation. Unlike [`local_have_oids`], this intentionally omits
670/// trees and blobs: Git's negotiator advances through commit history in
671/// bounded batches, allowing the server to distinguish an immediately common
672/// tip from a common ancestor reached only in a later round.
673pub(crate) fn local_negotiation_have_oids(
674    git_dir: &Path,
675    format: ObjectFormat,
676) -> Result<Vec<ObjectId>> {
677    local_negotiation_have_oids_stopping_at(git_dir, format, &HashSet::new())
678}
679
680/// Commit haves ordered like [`local_negotiation_have_oids`], but stop walking
681/// behind commits the remote advertised as ref tips. The advertisement is a
682/// promise that the server already has the tip and its reachable history, so
683/// sending ancestors of that tip only wastes negotiation lines. This mirrors
684/// fetch-pack's `mark_tips()` boundary for the default/consecutive negotiator.
685fn local_negotiation_have_oids_pruned_by_remote_tips(
686    git_dir: &Path,
687    remote_git_dir: &Path,
688    format: ObjectFormat,
689) -> Result<Vec<ObjectId>> {
690    let remote_db = FileObjectDatabase::from_git_dir(remote_git_dir, format)
691        .with_promisor_remote_present(repo_has_promisor_remote(remote_git_dir));
692    let mut advertised_commits = HashSet::new();
693    for advertisement in local_fetch_advertisements(remote_git_dir, format)? {
694        // A corrupt advertisement is diagnosed later when its ref is wanted.
695        // It cannot establish a negotiation frontier because the server does
696        // not actually own the advertised object.
697        if !remote_db.contains(&advertisement.oid)? {
698            continue;
699        }
700        if let Some(commit) =
701            peel_to_commit_for_negotiation(&remote_db, format, &advertisement.oid)?
702        {
703            advertised_commits.insert(commit);
704        }
705    }
706    local_negotiation_have_oids_stopping_at(git_dir, format, &advertised_commits)
707}
708
709fn local_negotiation_have_oids_stopping_at(
710    git_dir: &Path,
711    format: ObjectFormat,
712    stop_commits: &HashSet<ObjectId>,
713) -> Result<Vec<ObjectId>> {
714    let db = FileObjectDatabase::from_git_dir(git_dir, format)
715        .with_promisor_remote_present(repo_has_promisor_remote(git_dir));
716    let mut seen = HashSet::new();
717    let mut queue = BinaryHeap::new();
718    let commit_time = |oid: &ObjectId| -> Result<i64> {
719        let object = db.read_object(oid)?;
720        Ok(Commit::parse_ref(format, &object.body)?
721            .committer_signature()
722            .map(|signature| signature.time.seconds)
723            .unwrap_or(0))
724    };
725    for advertisement in local_fetch_advertisements(git_dir, format)? {
726        if let Some(commit) = peel_to_commit_for_negotiation(&db, format, &advertisement.oid)?
727            && seen.insert(commit)
728        {
729            queue.push((commit_time(&commit)?, commit));
730        }
731    }
732
733    let mut haves = Vec::new();
734    while let Some((_time, oid)) = queue.pop() {
735        haves.push(oid);
736        if stop_commits.contains(&oid) {
737            continue;
738        }
739        let object = db.read_object(&oid)?;
740        for parent in
741            sley_odb::grafted_parents(&db, &oid, Commit::parse_ref(format, &object.body)?.parents)
742        {
743            if seen.insert(parent) {
744                queue.push((commit_time(&parent)?, parent));
745            }
746        }
747    }
748    Ok(haves)
749}
750
751/// The in-process upload-pack's plan for a `deepen` (shallow) local fetch:
752/// which `shallow`/`unshallow` updates to report, which commits the pack walk
753/// must stop at, and which extra tips become packable because the client's
754/// boundary moved.
755///
756/// Mirrors upstream `upload-pack.c::deepen` + `shallow.c::get_shallow_commits`.
757#[derive(Debug, Clone)]
758pub struct LocalDeepenPlan {
759    /// The requested deepen depth (`--depth N`; [`INFINITE_DEPTH`] for
760    /// `--unshallow` and for the implicit deepen a shallow server runs on a
761    /// plain fetch; `0` for the deepen-since/deepen-not rev-list modes).
762    pub depth: u32,
763    /// The request carried `deepen-since` (trace2 `fetch-info` parity).
764    pub deepen_since: bool,
765    /// Number of `deepen-not` entries in the request (trace2 parity).
766    pub deepen_not: usize,
767    /// The client's existing shallow boundary (`$GIT_DIR/shallow`), replayed as
768    /// `shallow` lines in the upload-pack request.
769    pub client_shallow: Vec<ObjectId>,
770    /// The server's `shallow`/`unshallow` updates the client must fold into
771    /// `$GIT_DIR/shallow` after the pack lands (see [`crate::apply_shallow_info`]).
772    pub shallow_info: Vec<ProtocolV2FetchShallowInfo>,
773    /// Out-of-boundary commits (the parents of boundary commits that are not
774    /// themselves within the boundary): excluding these from the pack walk
775    /// truncates history at the boundary while keeping every tree/blob of the
776    /// boundary commits themselves.
777    pub excluded: HashSet<ObjectId>,
778    /// Parents of client-shallow commits this deepen un-shallowed, added as
779    /// extra pack tips so the newly visible history is sent (upload-pack adds
780    /// them to `want_obj` in `send_unshallow`).
781    pub extra_wants: Vec<ObjectId>,
782}
783
784/// Dereference `oid` through any chain of annotated tags to a commit, or `None`
785/// when it ultimately points at a tree or blob (`deref_tag` in upstream
786/// `shallow.c`'s boundary walk).
787fn peel_to_commit<R: ObjectReader>(
788    remote_db: &R,
789    format: ObjectFormat,
790    oid: &ObjectId,
791) -> Result<Option<ObjectId>> {
792    let mut oid = *oid;
793    loop {
794        let object = remote_db.read_object(&oid)?;
795        match object.object_type {
796            ObjectType::Commit => return Ok(Some(oid)),
797            ObjectType::Tag => oid = Tag::parse_ref(format, &object.body)?.object,
798            _ => return Ok(None),
799        }
800    }
801}
802
803/// Negotiation-frontier variant of [`peel_to_commit`]. An annotated tag may be
804/// present in a promisor pack while its filtered target is legitimately absent.
805/// Such a tag cannot establish a commit-have boundary, but it must not abort an
806/// unrelated branch fetch. Missing objects not proven promised remain errors.
807fn peel_to_commit_for_negotiation<R: ObjectReader>(
808    remote_db: &R,
809    format: ObjectFormat,
810    oid: &ObjectId,
811) -> Result<Option<ObjectId>> {
812    let mut oid = *oid;
813    loop {
814        let object = match remote_db.read_object(&oid) {
815            Ok(object) => object,
816            Err(GitError::NotFound(_)) if remote_db.is_promised_object(&oid) => return Ok(None),
817            Err(error) => return Err(error),
818        };
819        match object.object_type {
820            ObjectType::Commit => return Ok(Some(oid)),
821            ObjectType::Tag => oid = Tag::parse_ref(format, &object.body)?.object,
822            _ => return Ok(None),
823        }
824    }
825}
826
827/// Compute the deepen plan for a shallow local fetch, mirroring upstream
828/// `shallow.c::get_shallow_commits`: a breadth-first minimum-depth walk from the
829/// (tag-dereferenced) `heads` — the primary planned tips, upload-pack's
830/// `want_obj`, NOT auto-followed tags — where tips enter at depth 0 and a commit
831/// processed at depth `d` is a boundary commit when `d + 1 >= depth` (it is
832/// packed, but its parents are not walked).
833///
834/// `client_shallow` is the client's current boundary: boundary commits the
835/// client already has are not re-reported (`send_shallow` skips
836/// `CLIENT_SHALLOW`), and client-shallow commits now within the boundary are
837/// reported as `unshallow` with their parents returned as extra pack tips
838/// (`send_unshallow`).
839pub fn compute_local_deepen<R: ObjectReader>(
840    remote_db: &R,
841    format: ObjectFormat,
842    heads: &[ObjectId],
843    client_shallow: Vec<ObjectId>,
844    depth: u32,
845    deepen_relative: bool,
846) -> Result<LocalDeepenPlan> {
847    // `--deepen=N`: the boundary moves N commits past the client's current
848    // boundary (upstream `get_shallows_depth` + `depth +=`).
849    let depth = if deepen_relative && depth < INFINITE_DEPTH {
850        depth.saturating_add(client_shallow_min_depth(
851            remote_db,
852            format,
853            heads,
854            &client_shallow,
855        )?)
856    } else {
857        depth
858    };
859    let mut min_depth: HashMap<ObjectId, u32> = HashMap::new();
860    let mut queue: VecDeque<ObjectId> = VecDeque::new();
861    for head in heads {
862        let Some(commit) = peel_to_commit(remote_db, format, head)? else {
863            continue;
864        };
865        if let std::collections::hash_map::Entry::Vacant(entry) = min_depth.entry(commit) {
866            entry.insert(0);
867            queue.push_back(commit);
868        }
869    }
870    // FIFO processing with uniform edge weight makes the first visit the
871    // minimum depth, so each commit is processed exactly once and expands its
872    // parents only when it is within the boundary — the same fixpoint as
873    // upstream's decrease-key re-walks.
874    let mut boundary = Vec::new();
875    let mut boundary_parents = HashSet::new();
876    while let Some(oid) = queue.pop_front() {
877        let commit_depth = min_depth[&oid];
878        let object = remote_db.read_object(&oid)?;
879        let parents = sley_odb::grafted_parents(
880            remote_db,
881            &oid,
882            Commit::parse_ref(format, &object.body)?.parents,
883        );
884        // A commit is boundary when the requested depth cuts at it, or when
885        // the server's own history is cut at it (a shallow server reports its
886        // graft points to the client — upstream `get_shallows_or_depth`).
887        if (depth != INFINITE_DEPTH && commit_depth + 1 >= depth)
888            || remote_db.is_shallow_graft(&oid)
889        {
890            boundary.push(oid);
891            boundary_parents.extend(parents);
892            continue;
893        }
894        for parent in parents {
895            if let std::collections::hash_map::Entry::Vacant(entry) = min_depth.entry(parent) {
896                entry.insert(commit_depth + 1);
897                queue.push_back(parent);
898            }
899        }
900    }
901    // A boundary commit's parent can itself be within the boundary via a
902    // shorter path (and is then packed); only parents the walk never reached
903    // are excluded.
904    let excluded = boundary_parents
905        .into_iter()
906        .filter(|parent| !min_depth.contains_key(parent))
907        .collect::<HashSet<_>>();
908
909    let client: HashSet<ObjectId> = client_shallow.iter().copied().collect();
910    let boundary_set: HashSet<ObjectId> = boundary.iter().copied().collect();
911    let mut shallow_info = Vec::new();
912    for oid in &boundary {
913        if !client.contains(oid) {
914            shallow_info.push(ProtocolV2FetchShallowInfo::Shallow(*oid));
915        }
916    }
917    let mut extra_wants = Vec::new();
918    for oid in &client_shallow {
919        // A client-shallow commit is unshallowed when the walk reached it as
920        // a non-boundary commit (upstream `send_unshallow`: NOT_SHALLOW set).
921        let unshallowed = min_depth.contains_key(oid) && !boundary_set.contains(oid);
922        if !unshallowed {
923            continue;
924        }
925        shallow_info.push(ProtocolV2FetchShallowInfo::Unshallow(*oid));
926        let object = remote_db.read_object(oid)?;
927        extra_wants.extend(sley_odb::grafted_parents(
928            remote_db,
929            oid,
930            Commit::parse_ref(format, &object.body)?.parents,
931        ));
932    }
933    Ok(LocalDeepenPlan {
934        depth,
935        deepen_since: false,
936        deepen_not: 0,
937        client_shallow,
938        shallow_info,
939        excluded,
940        extra_wants,
941    })
942}
943
944/// Upstream `INFINITE_DEPTH`: `--unshallow`, and the implicit deepen a shallow
945/// server runs for a plain fetch so its graft points reach the client.
946pub const INFINITE_DEPTH: u32 = 0x7fff_ffff;
947
948/// Upstream `get_shallows_depth`: the minimum depth (head = 1) at which the
949/// walk from `heads` meets one of the client's shallow points, or 0 when it
950/// never does. Used to make `--deepen=N` relative to the current boundary.
951fn client_shallow_min_depth<R: ObjectReader>(
952    remote_db: &R,
953    format: ObjectFormat,
954    heads: &[ObjectId],
955    client_shallow: &[ObjectId],
956) -> Result<u32> {
957    if client_shallow.is_empty() {
958        return Ok(0);
959    }
960    let client: HashSet<ObjectId> = client_shallow.iter().copied().collect();
961    let mut min_depth: HashMap<ObjectId, u32> = HashMap::new();
962    let mut queue: VecDeque<ObjectId> = VecDeque::new();
963    for head in heads {
964        let Some(commit) = peel_to_commit(remote_db, format, head)? else {
965            continue;
966        };
967        if let std::collections::hash_map::Entry::Vacant(entry) = min_depth.entry(commit) {
968            entry.insert(1);
969            queue.push_back(commit);
970        }
971    }
972    let mut best: u32 = 0;
973    while let Some(oid) = queue.pop_front() {
974        let commit_depth = min_depth[&oid];
975        if client.contains(&oid) && (best == 0 || commit_depth < best) {
976            best = commit_depth;
977        }
978        let object = remote_db.read_object(&oid)?;
979        let parents = sley_odb::grafted_parents(
980            remote_db,
981            &oid,
982            Commit::parse_ref(format, &object.body)?.parents,
983        );
984        for parent in parents {
985            if let std::collections::hash_map::Entry::Vacant(entry) = min_depth.entry(parent) {
986                entry.insert(commit_depth + 1);
987                queue.push_back(parent);
988            }
989        }
990    }
991    Ok(best)
992}
993
994/// Deepen plan for the rev-list modes (`--shallow-since`, `--shallow-exclude`),
995/// mirroring upstream `get_shallow_commits_by_rev_list`: the kept set is every
996/// commit reachable from `heads` that is newer than `since` (when given) and
997/// not reachable from a `deepen_not` tip; the boundary is every kept commit
998/// with at least one parent outside the kept set.
999pub fn compute_local_deepen_by_rev_list<R: ObjectReader>(
1000    remote_db: &R,
1001    format: ObjectFormat,
1002    heads: &[ObjectId],
1003    client_shallow: Vec<ObjectId>,
1004    since: Option<i64>,
1005    deepen_not: &[ObjectId],
1006) -> Result<LocalDeepenPlan> {
1007    // Closure of the deepen-not tips (commits to subtract from the kept set).
1008    let mut excluded_not: HashSet<ObjectId> = HashSet::new();
1009    let mut queue: VecDeque<ObjectId> = VecDeque::new();
1010    for tip in deepen_not {
1011        if let Some(commit) = peel_to_commit(remote_db, format, tip)?
1012            && excluded_not.insert(commit)
1013        {
1014            queue.push_back(commit);
1015        }
1016    }
1017    while let Some(oid) = queue.pop_front() {
1018        let object = remote_db.read_object(&oid)?;
1019        for parent in sley_odb::grafted_parents(
1020            remote_db,
1021            &oid,
1022            Commit::parse_ref(format, &object.body)?.parents,
1023        ) {
1024            if excluded_not.insert(parent) {
1025                queue.push_back(parent);
1026            }
1027        }
1028    }
1029
1030    let commit_time = |oid: &ObjectId| -> Result<i64> {
1031        let object = remote_db.read_object(oid)?;
1032        Ok(Commit::parse_ref(format, &object.body)?
1033            .committer_signature()
1034            .map(|signature| signature.time.seconds)
1035            .unwrap_or(0))
1036    };
1037    let keeps = |oid: &ObjectId| -> Result<bool> {
1038        if excluded_not.contains(oid) {
1039            return Ok(false);
1040        }
1041        match since {
1042            Some(since) => Ok(commit_time(oid)? >= since),
1043            None => Ok(true),
1044        }
1045    };
1046
1047    // Kept-set walk: only kept commits are expanded, so the walk never reads
1048    // objects past the cut (and stops at server graft points via the seam).
1049    let mut kept: HashSet<ObjectId> = HashSet::new();
1050    let mut kept_order: Vec<ObjectId> = Vec::new();
1051    let mut queue: VecDeque<ObjectId> = VecDeque::new();
1052    for head in heads {
1053        let Some(commit) = peel_to_commit(remote_db, format, head)? else {
1054            continue;
1055        };
1056        if keeps(&commit)? && kept.insert(commit) {
1057            kept_order.push(commit);
1058            queue.push_back(commit);
1059        }
1060    }
1061    while let Some(oid) = queue.pop_front() {
1062        let object = remote_db.read_object(&oid)?;
1063        for parent in sley_odb::grafted_parents(
1064            remote_db,
1065            &oid,
1066            Commit::parse_ref(format, &object.body)?.parents,
1067        ) {
1068            if !kept.contains(&parent) && keeps(&parent)? {
1069                kept.insert(parent);
1070                kept_order.push(parent);
1071                queue.push_back(parent);
1072            }
1073        }
1074    }
1075    if kept.is_empty() {
1076        // Upstream `get_shallow_commits_by_rev_list` dies here.
1077        return Err(GitError::Command(
1078            "no commits selected for shallow requests".into(),
1079        ));
1080    }
1081
1082    // Boundary: kept commits with a parent outside the kept set.
1083    let mut boundary = Vec::new();
1084    let mut boundary_set: HashSet<ObjectId> = HashSet::new();
1085    let mut excluded: HashSet<ObjectId> = HashSet::new();
1086    for oid in &kept_order {
1087        let object = remote_db.read_object(oid)?;
1088        let parents = sley_odb::grafted_parents(
1089            remote_db,
1090            oid,
1091            Commit::parse_ref(format, &object.body)?.parents,
1092        );
1093        let mut is_boundary = false;
1094        for parent in parents {
1095            if !kept.contains(&parent) {
1096                is_boundary = true;
1097                excluded.insert(parent);
1098            }
1099        }
1100        if is_boundary && boundary_set.insert(*oid) {
1101            boundary.push(*oid);
1102        }
1103    }
1104
1105    let client: HashSet<ObjectId> = client_shallow.iter().copied().collect();
1106    let mut shallow_info = Vec::new();
1107    for oid in &boundary {
1108        if !client.contains(oid) {
1109            shallow_info.push(ProtocolV2FetchShallowInfo::Shallow(*oid));
1110        }
1111    }
1112    let mut extra_wants = Vec::new();
1113    for oid in &client_shallow {
1114        let unshallowed = kept.contains(oid) && !boundary_set.contains(oid);
1115        if !unshallowed {
1116            continue;
1117        }
1118        shallow_info.push(ProtocolV2FetchShallowInfo::Unshallow(*oid));
1119        let object = remote_db.read_object(oid)?;
1120        extra_wants.extend(sley_odb::grafted_parents(
1121            remote_db,
1122            oid,
1123            Commit::parse_ref(format, &object.body)?.parents,
1124        ));
1125    }
1126    Ok(LocalDeepenPlan {
1127        depth: 0,
1128        deepen_since: since.is_some(),
1129        deepen_not: deepen_not.len(),
1130        client_shallow,
1131        shallow_info,
1132        excluded,
1133        extra_wants,
1134    })
1135}
1136
1137const INITIAL_NEGOTIATION_BATCH: usize = 16;
1138const MAX_IN_VAIN: usize = 256;
1139
1140#[derive(Debug, Default)]
1141struct LocalNegotiationOutcome {
1142    common_haves: Vec<ObjectId>,
1143    total_rounds: usize,
1144}
1145
1146/// Run the in-process transport through protocol-v2 fetch-pack's multi-round
1147/// state. Haves are sent in exponentially growing batches; an ACK resets
1148/// `in_vain`, and the client only gives up after 256 further haves once an ACK
1149/// has been observed. Requests and responses round-trip through the upload-pack
1150/// codecs, so trace data reflects the wire-driven state rather than a count
1151/// inferred after the pack has already been selected.
1152fn negotiate_local_upload_pack(
1153    remote_db: &FileObjectDatabase,
1154    format: ObjectFormat,
1155    wants: &[ObjectId],
1156    haves: Vec<ObjectId>,
1157) -> Result<LocalNegotiationOutcome> {
1158    let wanted_commits = wants
1159        .iter()
1160        .map(|want| {
1161            if remote_db.contains(want)? {
1162                peel_to_commit(remote_db, format, want)
1163            } else {
1164                // Preserve the fetch path's typed "requested missing object"
1165                // diagnostic after negotiation; a corrupt advertisement cannot
1166                // participate in readiness.
1167                Ok(None)
1168            }
1169        })
1170        .collect::<Result<Vec<_>>>()?;
1171    let mut outcome = LocalNegotiationOutcome::default();
1172    let mut next_have = 0usize;
1173    let mut batch_size = INITIAL_NEGOTIATION_BATCH;
1174    let mut in_vain = 0usize;
1175    let mut seen_ack = false;
1176    let mut common = HashSet::new();
1177
1178    loop {
1179        outcome.total_rounds += 1;
1180        let end = next_have.saturating_add(batch_size).min(haves.len());
1181        let batch = haves[next_have..end].to_vec();
1182        next_have = end;
1183        in_vain = in_vain.saturating_add(batch.len());
1184        let done = batch.is_empty() || (seen_ack && in_vain >= MAX_IN_VAIN);
1185
1186        let mut request_bytes = Vec::new();
1187        write_upload_pack_negotiation_request(
1188            &mut request_bytes,
1189            &UploadPackNegotiationRequest { haves: batch, done },
1190        )?;
1191        let decoded = read_upload_pack_negotiation_request(format, &mut request_bytes.as_slice())?;
1192        if decoded.done {
1193            break;
1194        }
1195
1196        let mut round_common = Vec::new();
1197        for oid in &decoded.haves {
1198            if remote_db.contains(oid)? && common.insert(*oid) {
1199                round_common.push(*oid);
1200            }
1201        }
1202        let ready = !round_common.is_empty()
1203            && common_covers_wanted_commits(remote_db, format, &wanted_commits, &common)?;
1204        let mut acknowledgments = if round_common.is_empty() {
1205            vec![UploadPackAcknowledgment::Nak]
1206        } else {
1207            round_common
1208                .iter()
1209                .enumerate()
1210                .map(|(index, oid)| UploadPackAcknowledgment::Ack {
1211                    oid: *oid,
1212                    status: Some(if ready && index + 1 == round_common.len() {
1213                        UploadPackAckStatus::Ready
1214                    } else {
1215                        UploadPackAckStatus::Common
1216                    }),
1217                })
1218                .collect::<Vec<_>>()
1219        };
1220        let mut response_bytes = Vec::new();
1221        for acknowledgment in &acknowledgments {
1222            write_upload_pack_acknowledgment(&mut response_bytes, acknowledgment)?;
1223        }
1224        acknowledgments.clear();
1225        let mut response = response_bytes.as_slice();
1226        while !response.is_empty() {
1227            acknowledgments.push(read_upload_pack_acknowledgment(format, &mut response)?);
1228        }
1229        let mut received_ready = false;
1230        for acknowledgment in acknowledgments {
1231            if let UploadPackAcknowledgment::Ack { oid, status } = acknowledgment {
1232                seen_ack = true;
1233                in_vain = 0;
1234                outcome.common_haves.push(oid);
1235                received_ready |= status == Some(UploadPackAckStatus::Ready);
1236            }
1237        }
1238        if received_ready {
1239            break;
1240        }
1241        batch_size = batch_size.saturating_mul(2);
1242    }
1243    Ok(outcome)
1244}
1245
1246fn common_covers_wanted_commits(
1247    remote_db: &FileObjectDatabase,
1248    format: ObjectFormat,
1249    wanted_commits: &[Option<ObjectId>],
1250    common: &HashSet<ObjectId>,
1251) -> Result<bool> {
1252    for wanted in wanted_commits.iter().flatten() {
1253        let mut stack = vec![*wanted];
1254        let mut seen = HashSet::new();
1255        let mut covered = false;
1256        while let Some(oid) = stack.pop() {
1257            if !seen.insert(oid) {
1258                continue;
1259            }
1260            if common.contains(&oid) {
1261                covered = true;
1262                break;
1263            }
1264            let object = remote_db.read_object(&oid)?;
1265            stack.extend(sley_odb::grafted_parents(
1266                remote_db,
1267                &oid,
1268                Commit::parse_ref(format, &object.body)?.parents,
1269            ));
1270        }
1271        if !covered {
1272            return Ok(false);
1273        }
1274    }
1275    Ok(true)
1276}
1277
1278/// Fetch `wants` from a local repository at `remote_git_dir` into the repository
1279/// at `git_dir`, round-tripping the request and response through the protocol
1280/// codecs into the in-process upload-pack so the local path exercises the same
1281/// wire format as the networked transports. Objects already present locally are
1282/// skipped; `promisor` selects promisor-pack installation.
1283///
1284/// When `deepen` carries a [`LocalDeepenPlan`] (computed by the caller from the
1285/// primary planned tips via [`compute_local_deepen`]), the fetch is shallow: the
1286/// request replays the client's boundary as `shallow` lines plus a `deepen`
1287/// line, the pack walk stops at the plan's boundary, and the returned
1288/// shallow-info updates must be folded into `$GIT_DIR/shallow` (see
1289/// [`crate::apply_shallow_info`]). Empty for a full fetch.
1290#[allow(clippy::too_many_arguments)]
1291pub fn install_fetch_pack_via_local_upload_pack(
1292    git_dir: &Path,
1293    remote_git_dir: &Path,
1294    format: ObjectFormat,
1295    wants: Vec<ObjectId>,
1296    deepen: Option<&LocalDeepenPlan>,
1297    promisor: bool,
1298    record_promisor_refs: bool,
1299    filter: Option<sley_odb::PackObjectFilter>,
1300    custom_haves: Option<Vec<ObjectId>>,
1301    refetch: bool,
1302    unpack_limit: Option<usize>,
1303) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
1304    install_fetch_pack_via_local_upload_pack_with_promisor_decision(
1305        git_dir,
1306        remote_git_dir,
1307        format,
1308        wants,
1309        deepen,
1310        promisor,
1311        record_promisor_refs,
1312        filter,
1313        custom_haves,
1314        refetch,
1315        unpack_limit,
1316        &crate::PromisorRemoteDecision::default(),
1317    )
1318}
1319
1320/// Local upload-pack with the typed protocol-v2 promisor decision carried into
1321/// pack traversal. Accepted remotes allow promised gaps to be omitted; an empty
1322/// decision requires the server to hydrate those gaps from its configured
1323/// local/file promisors before constructing the transfer pack.
1324#[allow(clippy::too_many_arguments)]
1325pub fn install_fetch_pack_via_local_upload_pack_with_promisor_decision(
1326    git_dir: &Path,
1327    remote_git_dir: &Path,
1328    format: ObjectFormat,
1329    wants: Vec<ObjectId>,
1330    deepen: Option<&LocalDeepenPlan>,
1331    promisor: bool,
1332    record_promisor_refs: bool,
1333    filter: Option<sley_odb::PackObjectFilter>,
1334    custom_haves: Option<Vec<ObjectId>>,
1335    refetch: bool,
1336    unpack_limit: Option<usize>,
1337    promisor_decision: &crate::PromisorRemoteDecision,
1338) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
1339    install_fetch_pack_via_local_upload_pack_with_promisor_decision_into(
1340        git_dir,
1341        git_dir,
1342        remote_git_dir,
1343        format,
1344        wants,
1345        deepen,
1346        promisor,
1347        record_promisor_refs,
1348        filter,
1349        custom_haves,
1350        refetch,
1351        unpack_limit,
1352        promisor_decision,
1353        LocalFetchPackRequestMode::ExactObjects,
1354    )
1355    .map(|outcome| outcome.shallow_info)
1356}
1357
1358#[derive(Debug, Default)]
1359pub(crate) struct LocalFetchPackOutcome {
1360    pub shallow_info: Vec<ProtocolV2FetchShallowInfo>,
1361    pub object_count: usize,
1362    pub compression_count: usize,
1363    pub delta_count: u32,
1364}
1365
1366#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1367pub(crate) enum LocalFetchPackRequestMode {
1368    /// Traverse from the requested tips, applying any negotiated object filter.
1369    Traversal,
1370    /// Hydrate explicitly named objects, which must not be removed by the
1371    /// repository's ordinary partial-clone traversal filter.
1372    ExactObjects,
1373}
1374
1375#[allow(clippy::too_many_arguments)]
1376pub(crate) fn install_fetch_pack_via_local_upload_pack_with_promisor_decision_into(
1377    git_dir: &Path,
1378    destination_git_dir: &Path,
1379    remote_git_dir: &Path,
1380    format: ObjectFormat,
1381    wants: Vec<ObjectId>,
1382    deepen: Option<&LocalDeepenPlan>,
1383    promisor: bool,
1384    record_promisor_refs: bool,
1385    filter: Option<sley_odb::PackObjectFilter>,
1386    custom_haves: Option<Vec<ObjectId>>,
1387    refetch: bool,
1388    unpack_limit: Option<usize>,
1389    promisor_decision: &crate::PromisorRemoteDecision,
1390    request_mode: LocalFetchPackRequestMode,
1391) -> Result<LocalFetchPackOutcome> {
1392    if wants.is_empty() {
1393        return Ok(LocalFetchPackOutcome::default());
1394    }
1395    let local_db = FileObjectDatabase::from_git_dir(git_dir, format)
1396        .with_promisor_remote_present(repo_has_promisor_remote(git_dir));
1397    let all_wants_present = wants
1398        .iter()
1399        .map(|want| local_db.contains(want))
1400        .collect::<Result<Vec<_>>>()?
1401        .into_iter()
1402        .all(|contains| contains);
1403    let deepen_noop = match deepen {
1404        Some(plan) => plan.shallow_info.is_empty() && plan.extra_wants.is_empty(),
1405        None => true,
1406    };
1407    if all_wants_present && deepen_noop && !refetch {
1408        sley_protocol::trace_packet_write_payload(b"0000");
1409        return Ok(LocalFetchPackOutcome::default());
1410    }
1411
1412    // A lazy promisor request names the exact missing objects it needs. The
1413    // repository's partial-clone filter describes ordinary traversal, but it
1414    // must not remove an explicitly wanted blob from this direct response.
1415    let direct_promisor_object_fetch = promisor
1416        && deepen.is_none()
1417        && !record_promisor_refs
1418        && request_mode == LocalFetchPackRequestMode::ExactObjects;
1419    let transfer_filter = if direct_promisor_object_fetch {
1420        None
1421    } else {
1422        filter
1423    };
1424    let request = UploadPackRequest {
1425        wants,
1426        filter: transfer_filter
1427            .as_ref()
1428            .and_then(upload_pack_filter_protocol_spec),
1429        // The `shallow` capability accompanies a deepen request on the wire
1430        // (mirrors the SSH path); a plain fetch keeps its existing wire form.
1431        capabilities: deepen
1432            .map(|_| {
1433                vec![Capability {
1434                    name: "shallow".into(),
1435                    value: None,
1436                }]
1437            })
1438            .unwrap_or_default(),
1439        shallow: deepen
1440            .map(|plan| plan.client_shallow.clone())
1441            .unwrap_or_default(),
1442        deepen: deepen.and_then(|plan| (plan.depth > 0).then_some(plan.depth)),
1443        ..UploadPackRequest::default()
1444    };
1445    let mut encoded_request = Vec::new();
1446    write_upload_pack_request(&mut encoded_request, Some(&request))?;
1447    let decoded_request = read_upload_pack_request(format, &mut encoded_request.as_slice())?
1448        .ok_or_else(|| GitError::InvalidFormat("encoded upload-pack request was empty".into()))?;
1449
1450    // Lazy promisor hydration asks for exact missing objects; negotiating local
1451    // haves would walk the partial client's intentionally-missing blobs.
1452    if direct_promisor_object_fetch && local_upload_pack_client_wants_v2(git_dir) {
1453        trace_local_upload_pack_v2_capabilities(remote_git_dir, format);
1454    }
1455    let use_default_haves = !refetch && !direct_promisor_object_fetch && custom_haves.is_none();
1456    let haves = if refetch || direct_promisor_object_fetch {
1457        Vec::new()
1458    } else if let Some(haves) = custom_haves {
1459        haves
1460    } else {
1461        local_negotiation_have_oids_pruned_by_remote_tips(git_dir, remote_git_dir, format)?
1462    };
1463    let remote_has_promisor = repo_has_promisor_remote(remote_git_dir);
1464    let remote_db = FileObjectDatabase::from_git_dir(remote_git_dir, format)
1465        .with_promisor_remote_present(remote_has_promisor);
1466    let negotiation =
1467        negotiate_local_upload_pack(&remote_db, format, &decoded_request.wants, haves)?;
1468    sley_core::trace2::data("negotiation_v2", "total_rounds", negotiation.total_rounds);
1469    let mut known_haves = negotiation.common_haves;
1470    if use_default_haves {
1471        // The local transport can cheaply retain the full destination-object
1472        // exclusion set without putting every loose object on the simulated
1473        // wire. The packet negotiation above stays commit-only and
1474        // advertisement-pruned; this internal augmentation preserves support
1475        // for fetching from an intentionally incomplete local repository when
1476        // the destination already owns its missing delta/base objects.
1477        let mut seen = known_haves.iter().copied().collect::<HashSet<_>>();
1478        for oid in local_have_oids(git_dir, format)? {
1479            if seen.insert(oid) && remote_db.contains(&oid)? {
1480                known_haves.push(oid);
1481            }
1482        }
1483    }
1484    // Trace2 `fetch-info` parity: upstream upload-pack emits a data_json
1485    // event the shallow tests grep for; the in-process server inherits the
1486    // client's GIT_TRACE2_EVENT just like a spawned upload-pack would.
1487    trace2_fetch_info(
1488        known_haves.len(),
1489        decoded_request.wants.len(),
1490        deepen.map(|plan| plan.depth).unwrap_or(0),
1491        deepen.map(|plan| plan.client_shallow.len()).unwrap_or(0),
1492        deepen.is_some_and(|plan| plan.deepen_since),
1493        deepen.map(|plan| plan.deepen_not).unwrap_or(0),
1494        transfer_filter.as_ref(),
1495    );
1496    // With a deepen plan the haves walk is cut at the client's existing
1497    // boundary: having a commit inside the old shallow window must not imply
1498    // having the history below it (upstream runs pack-objects with the
1499    // client's shallow file for exactly this reason).
1500    let mut excluded = match deepen {
1501        Some(plan) => {
1502            let cut: HashSet<ObjectId> = plan.client_shallow.iter().copied().collect();
1503            let mut excluded = sley_odb::collect_reachable_object_ids_with_cut(
1504                &remote_db,
1505                format,
1506                known_haves,
1507                &cut,
1508            )?;
1509            // A `shallow <oid>` request line also proves that the client owns
1510            // the boundary commit itself. It only withholds knowledge of that
1511            // commit's parents. Exclude the boundary object while retaining
1512            // the cut so a deepen response can still send newly exposed
1513            // history below it.
1514            excluded.extend(plan.client_shallow.iter().copied());
1515            excluded
1516        }
1517        None => {
1518            // The negotiated haves describe the client's object graph. A local
1519            // remote may be intentionally incomplete while the client has the
1520            // missing bases already, so walk the exclusion closure locally and
1521            // keep the actual pack source pinned to the remote below.
1522            sley_odb::collect_reachable_object_ids_tolerating_promised_missing(
1523                &local_db,
1524                format,
1525                known_haves,
1526            )?
1527        }
1528    };
1529    let mut starts = decoded_request.wants;
1530    let promisor_ref_wants = starts.iter().copied().collect::<HashSet<_>>();
1531    if deepen.is_some() {
1532        // Deepening names the current tip again so upload-pack can plan the
1533        // boundary, but a client that already owns that tip must not receive a
1534        // duplicate loose copy. Newly exposed parents are added below through
1535        // `extra_wants`; retain only genuinely missing primary tips here.
1536        let mut missing_starts = Vec::with_capacity(starts.len());
1537        for oid in starts {
1538            if !local_db.contains(&oid)? {
1539                missing_starts.push(oid);
1540            }
1541        }
1542        starts = missing_starts;
1543    }
1544    for want in &starts {
1545        excluded.remove(want);
1546    }
1547    if let Some(plan) = deepen {
1548        // Stop the pack walk at the shallow boundary and pack the history a
1549        // moved boundary newly exposes.
1550        excluded.extend(plan.excluded.iter().copied());
1551        starts.extend(plan.extra_wants.iter().copied());
1552    }
1553    if remote_has_promisor && promisor_decision.accepted.is_empty() {
1554        hydrate_reachable_promised_objects(remote_git_dir, &remote_db, format, &starts, &excluded)?;
1555    }
1556    for want in &starts {
1557        if !remote_db.contains(want)?
1558            && (promisor_decision.accepted.is_empty() || !remote_db.is_promised_object(want))
1559        {
1560            return Err(GitError::InvalidObject(format!(
1561                "upload-pack requested missing object {want}"
1562            )));
1563        }
1564    }
1565    let missing_policy = if promisor_decision.accepted.is_empty() {
1566        ReachablePackMissingPolicy::RequireComplete
1567    } else {
1568        ReachablePackMissingPolicy::OmitPromised
1569    };
1570    let destination_db = FileObjectDatabase::from_git_dir(destination_git_dir, format)
1571        .with_promisor_remote_present(repo_has_promisor_remote(git_dir));
1572    // A bitmap-assisted server knows the complete client-have closure cheaply.
1573    // Keep those objects excluded from the response, but expose a bounded set
1574    // to pack generation as external bases so buried old blobs can be reused in
1575    // a thin pack. Without bitmap traversal Git intentionally limits the have
1576    // walk and does not discover those deep bases.
1577    let thin_base_candidates = if local_upload_pack_uses_bitmaps(remote_git_dir) {
1578        ReachablePackThinBaseCandidates::from_object_ids(&excluded)
1579    } else {
1580        ReachablePackThinBaseCandidates::default()
1581    };
1582    let transfer = build_and_install_reachable_pack_filtered_with_thin_bases(
1583        &remote_db,
1584        &destination_db,
1585        format,
1586        starts,
1587        &excluded,
1588        RawPackInstallOptions {
1589            promisor,
1590            ..Default::default()
1591        },
1592        transfer_filter,
1593        unpack_limit,
1594        missing_policy,
1595        thin_base_candidates,
1596    )?;
1597    if promisor
1598        && record_promisor_refs
1599        && let Some(result) = transfer.install.as_ref()
1600        && let Some(promisor_path) = result.promisor_path.as_ref()
1601    {
1602        append_promisor_ref_lines(promisor_path, remote_git_dir, format, &promisor_ref_wants)?;
1603    }
1604    Ok(LocalFetchPackOutcome {
1605        shallow_info: deepen
1606            .map(|plan| plan.shallow_info.clone())
1607            .unwrap_or_default(),
1608        object_count: transfer.object_count,
1609        compression_count: transfer.compression_count,
1610        delta_count: transfer.delta_count,
1611    })
1612}
1613
1614/// Hydrate the requested object IDs from configured local/file promisor
1615/// remotes, returning the subset that was installed.
1616///
1617/// Remotes are tried in [`crate::configured_promisor_remote_names`] order. A
1618/// source that cannot provide an object is skipped so the next promisor can be
1619/// tried; non-local transports remain the caller's responsibility.
1620pub fn hydrate_objects_from_local_promisor_remotes(
1621    git_dir: &Path,
1622    format: ObjectFormat,
1623    objects: &[ObjectId],
1624) -> Result<Vec<ObjectId>> {
1625    let db = FileObjectDatabase::from_git_dir(git_dir, format)
1626        .with_promisor_remote_present(repo_has_promisor_remote(git_dir));
1627    let config = sley_config::read_repo_config(git_dir, None).unwrap_or_default();
1628    let relative_base = if git_dir.file_name().is_some_and(|name| name == ".git") {
1629        git_dir.parent().unwrap_or(git_dir)
1630    } else {
1631        git_dir
1632    };
1633    let mut missing = objects
1634        .iter()
1635        .copied()
1636        .filter_map(|oid| match db.contains(&oid) {
1637            Ok(false) => Some(Ok(oid)),
1638            Ok(true) => None,
1639            Err(err) => Some(Err(err)),
1640        })
1641        .collect::<Result<Vec<_>>>()?;
1642    missing.sort_by_key(ObjectId::to_hex);
1643    missing.dedup();
1644    let requested = missing.clone();
1645
1646    for remote_name in crate::configured_promisor_remote_names(&config) {
1647        let Some(url) = config
1648            .get("remote", Some(&remote_name), "url")
1649            .filter(|url| !url.is_empty())
1650        else {
1651            continue;
1652        };
1653        let Ok(crate::fetch::FetchSource::Local {
1654            git_dir: promisor_git_dir,
1655            ..
1656        }) = crate::fetch_source_for_url(url, relative_base)
1657        else {
1658            continue;
1659        };
1660        crate::promisor::trace_promisor_remote_contact(&remote_name);
1661        for oid in missing.iter().copied() {
1662            let _ = install_fetch_pack_via_local_upload_pack(
1663                git_dir,
1664                &promisor_git_dir,
1665                format,
1666                vec![oid],
1667                None,
1668                true,
1669                false,
1670                Some(sley_odb::PackObjectFilter::BlobNone),
1671                Some(Vec::new()),
1672                false,
1673                None,
1674            );
1675        }
1676        db.refresh_read_cache();
1677        missing.retain(|oid| !db.contains(oid).unwrap_or(false));
1678        if missing.is_empty() {
1679            break;
1680        }
1681    }
1682
1683    Ok(requested
1684        .into_iter()
1685        .filter(|oid| db.contains(oid).unwrap_or(false))
1686        .collect())
1687}
1688
1689/// Hydrate every missing object in `starts`' reachable closure from configured
1690/// local promisor remotes.
1691///
1692/// This is used by operations such as a full repack after `.promisor`
1693/// sidecars were deliberately removed: repository configuration still proves
1694/// the remote is a promisor even though no local sidecar can classify the gap.
1695pub fn hydrate_reachable_from_local_promisor_remotes(
1696    remote_git_dir: &Path,
1697    format: ObjectFormat,
1698    starts: &[ObjectId],
1699) -> Result<()> {
1700    let remote_db =
1701        FileObjectDatabase::from_git_dir(remote_git_dir, format).with_promisor_remote_present(true);
1702    hydrate_reachable_promised_objects(remote_git_dir, &remote_db, format, starts, &HashSet::new())
1703}
1704
1705fn hydrate_reachable_promised_objects(
1706    remote_git_dir: &Path,
1707    remote_db: &FileObjectDatabase,
1708    format: ObjectFormat,
1709    starts: &[ObjectId],
1710    excluded: &HashSet<ObjectId>,
1711) -> Result<()> {
1712    let config = sley_config::read_repo_config(remote_git_dir, None).unwrap_or_default();
1713    let relative_base = if remote_git_dir
1714        .file_name()
1715        .is_some_and(|name| name == ".git")
1716    {
1717        remote_git_dir.parent().unwrap_or(remote_git_dir)
1718    } else {
1719        remote_git_dir
1720    };
1721
1722    loop {
1723        let reachable = sley_odb::collect_reachable_object_ids_tolerating_missing(
1724            remote_db,
1725            format,
1726            starts.iter().copied(),
1727        )?;
1728        let mut missing = reachable
1729            .into_iter()
1730            .filter(|oid| !excluded.contains(oid))
1731            .filter_map(|oid| match remote_db.contains(&oid) {
1732                Ok(false) => Some(Ok(oid)),
1733                Ok(_) => None,
1734                Err(err) => Some(Err(err)),
1735            })
1736            .collect::<Result<Vec<_>>>()?;
1737        missing.sort_by_key(ObjectId::to_hex);
1738        if missing.is_empty() {
1739            return Ok(());
1740        }
1741
1742        let before = missing.len();
1743        for remote_name in sley_config::remotes::remote_names(&config)
1744            .into_iter()
1745            .filter(|name| {
1746                config
1747                    .get_bool("remote", Some(name), "promisor")
1748                    .unwrap_or(false)
1749                    || config
1750                        .get("remote", Some(name), "partialCloneFilter")
1751                        .is_some_and(|value| !value.is_empty())
1752            })
1753        {
1754            let Some(url) = config
1755                .get("remote", Some(&remote_name), "url")
1756                .filter(|url| !url.is_empty())
1757            else {
1758                continue;
1759            };
1760            let Ok(crate::fetch::FetchSource::Local {
1761                git_dir: promisor_git_dir,
1762                ..
1763            }) = crate::fetch_source_for_url(url, relative_base)
1764            else {
1765                continue;
1766            };
1767            crate::promisor::trace_promisor_remote_contact(&remote_name);
1768            let remote_before = missing.len();
1769            for oid in missing.iter().copied() {
1770                let _ = install_fetch_pack_via_local_upload_pack(
1771                    remote_git_dir,
1772                    &promisor_git_dir,
1773                    format,
1774                    vec![oid],
1775                    None,
1776                    true,
1777                    false,
1778                    None,
1779                    Some(Vec::new()),
1780                    false,
1781                    None,
1782                );
1783            }
1784            remote_db.refresh_read_cache();
1785            missing.retain(|oid| !remote_db.contains(oid).unwrap_or(false));
1786            if missing.len() < remote_before
1787                && config.get("remote", Some(&remote_name), "partialCloneFilter")
1788                    != Some("blob:none")
1789            {
1790                crate::apply_promisor_remote_field_updates(
1791                    remote_git_dir,
1792                    &[crate::PromisorRemoteFieldUpdate {
1793                        remote_name: remote_name.clone(),
1794                        field: crate::PromisorRemoteField::PartialCloneFilter,
1795                        previous: config
1796                            .get("remote", Some(&remote_name), "partialCloneFilter")
1797                            .map(str::to_string),
1798                        value: "blob:none".into(),
1799                    }],
1800                )?;
1801            }
1802            if missing.is_empty() {
1803                break;
1804            }
1805        }
1806        if missing.len() == before {
1807            return Err(GitError::object_not_found(missing[0]));
1808        }
1809    }
1810}
1811
1812/// Inputs for one in-process protocol-v2 fetch using `want-ref`.
1813pub(crate) struct LocalProtocolV2FetchRequest<'a> {
1814    pub git_dir: &'a Path,
1815    pub destination_git_dir: &'a Path,
1816    pub remote_git_dir: &'a Path,
1817    pub format: ObjectFormat,
1818    pub wants: Vec<ObjectId>,
1819    pub want_refs: Vec<String>,
1820    pub haves: Option<Vec<ObjectId>>,
1821}
1822
1823/// Structured result of an in-process protocol-v2 `want-ref` fetch.
1824#[derive(Debug, Default)]
1825pub(crate) struct LocalProtocolV2FetchOutcome {
1826    pub wanted_refs: Vec<ProtocolV2FetchWantedRef>,
1827    pub shallow_info: Vec<ProtocolV2FetchShallowInfo>,
1828}
1829
1830/// Round-trip a normal, non-shallow local fetch through protocol v2 so a
1831/// ref-in-want capable server resolves ref names at request time rather than
1832/// relying on the earlier `ls-refs` snapshot.
1833pub(crate) fn install_fetch_pack_via_local_protocol_v2(
1834    input: LocalProtocolV2FetchRequest<'_>,
1835) -> Result<LocalProtocolV2FetchOutcome> {
1836    if input.wants.is_empty() && input.want_refs.is_empty() {
1837        return Ok(LocalProtocolV2FetchOutcome::default());
1838    }
1839
1840    let config = sley_config::read_repo_config(input.remote_git_dir, None).unwrap_or_default();
1841    let handshake = TransportHandshake {
1842        protocol: ProtocolVersion::V2,
1843        capabilities: upload_pack_v2_capabilities(input.format, &config)?,
1844    };
1845    let haves = input
1846        .haves
1847        .map(Ok)
1848        .unwrap_or_else(|| local_have_oids(input.git_dir, input.format))?;
1849    let local_db = FileObjectDatabase::from_git_dir(input.git_dir, input.format);
1850    let destination_db = FileObjectDatabase::from_git_dir(input.destination_git_dir, input.format);
1851    let remote_db = FileObjectDatabase::from_git_dir(input.remote_git_dir, input.format);
1852    let negotiation_rounds = protocol_v2_negotiation_rounds(&local_db, &remote_db, &haves)?;
1853    let fetch = ProtocolV2FetchRequest {
1854        wants: input.wants,
1855        want_refs: input.want_refs,
1856        haves,
1857        thin_pack: true,
1858        include_tag: true,
1859        ofs_delta: true,
1860        done: true,
1861        wait_for_done: true,
1862        ..ProtocolV2FetchRequest::default()
1863    };
1864
1865    // Exercise the same request codec and feature validation as a network
1866    // transport. Besides guarding the in-process boundary, the write emits the
1867    // exact `want`/`want-ref` packet trace expected from a v2 client.
1868    let mut request_bytes = Vec::new();
1869    write_protocol_v2_fetch_request(&mut request_bytes, &fetch)?;
1870    let command = read_protocol_v2_command_request(&mut request_bytes.as_slice())?;
1871    let decoded = match classify_protocol_v2_command_request(&handshake, input.format, &command)? {
1872        sley_protocol::ProtocolV2Command::Fetch(fetch) => fetch,
1873        _ => {
1874            return Err(GitError::InvalidFormat(
1875                "local protocol-v2 fetch decoded as a non-fetch command".into(),
1876            ));
1877        }
1878    };
1879
1880    let mut sections = local_fetch_v2_sections(input.remote_git_dir, input.format, &decoded)?;
1881    // If every resolved want is already local, the pack builder has nothing to
1882    // send. Keep the wanted-refs mapping but omit an empty packfile section.
1883    sections.retain(|section| {
1884        !matches!(section, ProtocolV2FetchResponseSection::Packfile(lines) if lines.is_empty())
1885    });
1886    let mut response_bytes = Vec::new();
1887    write_protocol_v2_fetch_response(&mut response_bytes, &sections)?;
1888
1889    let (header, _) = install_protocol_v2_fetch_response_from_reader(
1890        input.format,
1891        &mut response_bytes.as_slice(),
1892        false,
1893        &destination_db,
1894        None,
1895    )?;
1896    let mut outcome = LocalProtocolV2FetchOutcome::default();
1897    for section in header.sections {
1898        match section {
1899            ProtocolV2FetchResponseSection::WantedRefs(wanted) => {
1900                outcome.wanted_refs.extend(wanted);
1901            }
1902            ProtocolV2FetchResponseSection::ShallowInfo(shallow) => {
1903                outcome.shallow_info.extend(shallow);
1904            }
1905            _ => {}
1906        }
1907    }
1908    sley_core::trace2::data("negotiation_v2", "total_rounds", negotiation_rounds);
1909    Ok(outcome)
1910}
1911
1912fn protocol_v2_negotiation_rounds(
1913    local_db: &FileObjectDatabase,
1914    remote_db: &FileObjectDatabase,
1915    haves: &[ObjectId],
1916) -> Result<usize> {
1917    let mut missing_commits = 0usize;
1918    for oid in haves {
1919        if !remote_db.contains(oid)? && local_db.read_object(oid)?.object_type == ObjectType::Commit
1920        {
1921            missing_commits += 1;
1922        }
1923    }
1924    // Git's consecutive negotiator starts with sixteen commits and doubles
1925    // the next batch. Count the request/response rounds needed before a common
1926    // commit can be reached; the final batch is the round carrying `done`.
1927    let mut rounds = 1usize;
1928    let mut batch = 16usize;
1929    while missing_commits > batch {
1930        missing_commits -= batch;
1931        batch = batch.saturating_mul(2);
1932        rounds += 1;
1933    }
1934    Ok(rounds)
1935}
1936
1937fn local_upload_pack_client_wants_v2(git_dir: &Path) -> bool {
1938    sley_config::read_repo_config(git_dir, None)
1939        .ok()
1940        .and_then(|config| config.get("protocol", None, "version").map(str::to_string))
1941        .as_deref()
1942        == Some("2")
1943}
1944
1945fn local_upload_pack_uses_bitmaps(git_dir: &Path) -> bool {
1946    let configured = sley_config::read_repo_config(git_dir, None)
1947        .ok()
1948        .and_then(|config| config.get_bool("pack", None, "usebitmaps"));
1949    configured.unwrap_or_else(|| {
1950        fs::read_dir(git_dir.join("objects/pack"))
1951            .ok()
1952            .into_iter()
1953            .flatten()
1954            .filter_map(std::result::Result::ok)
1955            .any(|entry| entry.path().extension().is_some_and(|ext| ext == "bitmap"))
1956    })
1957}
1958
1959fn repo_has_promisor_remote(git_dir: &Path) -> bool {
1960    let Ok(config) = sley_config::read_repo_config(git_dir, None) else {
1961        return false;
1962    };
1963    if config
1964        .get("extensions", None, "partialclone")
1965        .is_some_and(|value| !value.is_empty())
1966    {
1967        return true;
1968    }
1969    config.sections.iter().any(|section| {
1970        section.name.eq_ignore_ascii_case("remote")
1971            && section
1972                .subsection
1973                .as_deref()
1974                .is_some_and(|name| config.get_bool("remote", Some(name), "promisor") == Some(true))
1975    })
1976}
1977
1978fn trace_local_upload_pack_v2_capabilities(remote_git_dir: &Path, format: ObjectFormat) {
1979    sley_protocol::set_packet_trace_identity("fetch");
1980    let config = sley_config::read_repo_config(remote_git_dir, None).unwrap_or_default();
1981    sley_protocol::trace_packet_read_payload(b"version 2\n");
1982    sley_protocol::trace_packet_read_payload(
1983        format!("agent={UPSTREAM_GIT_COMPAT_VERSION}\n").as_bytes(),
1984    );
1985    sley_protocol::trace_packet_read_payload(b"ls-refs=unborn\n");
1986    let mut fetch = "fetch=shallow wait-for-done".to_string();
1987    if config
1988        .get_bool("uploadpack", None, "allowfilter")
1989        .unwrap_or(false)
1990    {
1991        fetch.push_str(" filter");
1992    }
1993    if config
1994        .get_bool("uploadpack", None, "allowrefinwant")
1995        .unwrap_or(false)
1996    {
1997        fetch.push_str(" ref-in-want");
1998    }
1999    fetch.push('\n');
2000    sley_protocol::trace_packet_read_payload(fetch.as_bytes());
2001    sley_protocol::trace_packet_read_payload(
2002        format!("object-format={}\n", format.name()).as_bytes(),
2003    );
2004    sley_protocol::trace_packet_read_payload(b"0000");
2005}
2006
2007pub(crate) fn upload_pack_filter_protocol_spec(
2008    filter: &sley_odb::PackObjectFilter,
2009) -> Option<String> {
2010    match filter {
2011        sley_odb::PackObjectFilter::BlobNone => Some("blob:none".to_string()),
2012        sley_odb::PackObjectFilter::BlobLimit(limit) => Some(format!("blob:limit={limit}")),
2013        sley_odb::PackObjectFilter::TreeDepth(depth) => Some(format!("tree:{depth}")),
2014        sley_odb::PackObjectFilter::SparsePathSet(_) => None,
2015    }
2016}
2017
2018fn append_promisor_ref_lines(
2019    promisor_path: &Path,
2020    remote_git_dir: &Path,
2021    format: ObjectFormat,
2022    wanted: &HashSet<ObjectId>,
2023) -> Result<()> {
2024    if wanted.is_empty() {
2025        return Ok(());
2026    }
2027    let store = FileRefStore::new(remote_git_dir, format);
2028    let mut lines = Vec::new();
2029    if let Some(head_target) = store.read_ref("HEAD")? {
2030        let head = Ref {
2031            name: "HEAD".into(),
2032            target: head_target,
2033        };
2034        if let Some((oid, _)) = resolve_for_each_ref_target(&store, &head)?
2035            && wanted.contains(&oid)
2036        {
2037            lines.push(format!("{oid} HEAD\n"));
2038        }
2039    }
2040    for reference in store.list_refs()? {
2041        let Some((oid, _)) = resolve_for_each_ref_target(&store, &reference)? else {
2042            continue;
2043        };
2044        if wanted.contains(&oid) {
2045            lines.push(format!("{oid} {}\n", reference.name));
2046        }
2047    }
2048    if lines.is_empty() {
2049        return Ok(());
2050    }
2051    lines.sort();
2052    let mut file = fs::OpenOptions::new().append(true).open(promisor_path)?;
2053    use std::io::Write as _;
2054    for line in lines {
2055        file.write_all(line.as_bytes())?;
2056    }
2057    Ok(())
2058}
2059
2060/// Append upstream upload-pack's `fetch-info` data_json event to the file
2061/// named by `GIT_TRACE2_EVENT` (`trace2_fetch_info` in `upload-pack.c`). The
2062/// subset of fields the test suite greps is emitted with upstream spellings.
2063fn trace2_fetch_info(
2064    haves: usize,
2065    wants: usize,
2066    depth: u32,
2067    shallows: usize,
2068    deepen_since: bool,
2069    deepen_not: usize,
2070    filter: Option<&sley_odb::PackObjectFilter>,
2071) {
2072    let Some(path) = std::env::var_os("GIT_TRACE2_EVENT") else {
2073        return;
2074    };
2075    if path.is_empty() {
2076        return;
2077    }
2078    let filter_json = match filter {
2079        Some(sley_odb::PackObjectFilter::BlobNone) => "\"blob:none\"".to_string(),
2080        // upload-pack's trace2 `fetch-info` records the filter choice name,
2081        // not the normalized wire specification or its parameter.
2082        Some(sley_odb::PackObjectFilter::BlobLimit(_)) => "\"blob:limit\"".to_string(),
2083        Some(sley_odb::PackObjectFilter::TreeDepth(_)) => "\"tree\"".to_string(),
2084        Some(sley_odb::PackObjectFilter::SparsePathSet(_)) => "\"sparse:oid\"".to_string(),
2085        None => "null".to_string(),
2086    };
2087    let line = format!(
2088        "{{\"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"
2089    );
2090    if let Ok(mut file) = std::fs::OpenOptions::new()
2091        .create(true)
2092        .append(true)
2093        .open(&path)
2094    {
2095        use std::io::Write as _;
2096        let _ = file.write_all(line.as_bytes());
2097    }
2098}
2099
2100// ---------------------------------------------------------------------------
2101// Protocol v2 upload-pack server (`GIT_PROTOCOL=version=2`).
2102//
2103// Mirrors upstream `upload-pack.c::upload_pack_v2` / `serve.c`: advertise the
2104// v2 capabilities, then read `command=ls-refs` / `command=fetch` requests until
2105// EOF, answering each with the protocol-v2 response. The transport (file://
2106// spawned process, git:// daemon child) hands us a connected stdin/stdout pair;
2107// everything below is transport-independent.
2108// ---------------------------------------------------------------------------
2109
2110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2111enum LsRefsUnbornConfig {
2112    Ignore,
2113    Allow,
2114    Advertise,
2115}
2116
2117fn lsrefs_unborn_config(config: &GitConfig) -> LsRefsUnbornConfig {
2118    match config.get("lsrefs", None, "unborn") {
2119        Some("ignore") => LsRefsUnbornConfig::Ignore,
2120        Some("allow") => LsRefsUnbornConfig::Allow,
2121        Some("advertise") | None => LsRefsUnbornConfig::Advertise,
2122        Some(_) => LsRefsUnbornConfig::Advertise,
2123    }
2124}
2125
2126fn upload_pack_blob_packfile_uri_configured(config: &GitConfig) -> bool {
2127    config
2128        .get_all("uploadpack", None, "blobpackfileuri")
2129        .into_iter()
2130        .any(|value| value.is_some_and(|value| !value.is_empty()))
2131}
2132
2133/// The v2 capabilities advertised by the upload-pack server, in the order git
2134/// emits them: `agent`, `ls-refs[=unborn]`, `fetch=<features>`,
2135/// `server-option`, `object-format=<hash>`.
2136fn upload_pack_v2_capabilities(
2137    format: ObjectFormat,
2138    config: &GitConfig,
2139) -> Result<Vec<Capability>> {
2140    let mut capabilities = vec![
2141        Capability {
2142            name: "agent".into(),
2143            value: Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}")),
2144        },
2145        encode_protocol_v2_ls_refs_capability(&ProtocolV2LsRefsFeatures {
2146            unborn: lsrefs_unborn_config(config) == LsRefsUnbornConfig::Advertise,
2147            unknown: Vec::new(),
2148        })?,
2149        encode_protocol_v2_fetch_capability(&ProtocolV2FetchFeatures {
2150            shallow: true,
2151            wait_for_done: true,
2152            filter: config
2153                .get_bool("uploadpack", None, "allowfilter")
2154                .unwrap_or(false),
2155            ref_in_want: config
2156                .get_bool("uploadpack", None, "allowrefinwant")
2157                .unwrap_or(false),
2158            packfile_uris: upload_pack_blob_packfile_uri_configured(config),
2159            ..ProtocolV2FetchFeatures::default()
2160        })?,
2161        Capability {
2162            name: "server-option".into(),
2163            value: None,
2164        },
2165        Capability {
2166            name: "object-format".into(),
2167            value: Some(format.name().into()),
2168        },
2169    ];
2170    if config
2171        .get_bool("transfer", None, "advertisesid")
2172        .unwrap_or(false)
2173    {
2174        capabilities.push(Capability {
2175            name: "session-id".into(),
2176            value: Some("sley".into()),
2177        });
2178    }
2179    if let Some(capability) = crate::promisor_remote_server_capability(config)? {
2180        capabilities.push(capability);
2181    }
2182    // Advertise the `bundle-uri` command when the server is configured to hand
2183    // out bundle URIs (upstream `bundle_uri_advertise` reads
2184    // `uploadpack.advertisebundleuris`). The client then issues `command=bundle-uri`
2185    // to learn the `bundle.*` list before negotiating the pack.
2186    if config
2187        .get_bool("uploadpack", None, "advertisebundleuris")
2188        .unwrap_or(false)
2189    {
2190        capabilities.push(Capability {
2191            name: "bundle-uri".into(),
2192            value: None,
2193        });
2194    }
2195    Ok(capabilities)
2196}
2197
2198/// Resolve the symref target of `HEAD` (e.g. `refs/heads/main`) for the
2199/// `symrefs`/symref-target ls-refs attribute, following one level of symbolic
2200/// indirection. Returns `None` for a detached or missing `HEAD`.
2201fn head_symref_target(store: &FileRefStore) -> Result<Option<String>> {
2202    match store.read_ref("HEAD")? {
2203        Some(RefTarget::Symbolic(name)) => Ok(Some(name)),
2204        _ => Ok(None),
2205    }
2206}
2207
2208/// Build the protocol-v2 `ls-refs` records for the repository at `git_dir`,
2209/// honoring the request's `ref-prefix`, `peel`, `symrefs`, and `unborn`
2210/// arguments. Mirrors `ls-refs.c::ls_refs`.
2211fn local_ls_refs_v2_records(
2212    git_dir: &Path,
2213    format: ObjectFormat,
2214    request: &ProtocolV2LsRefsRequest,
2215    config: &GitConfig,
2216) -> Result<Vec<ProtocolV2LsRefsRecord>> {
2217    let store = FileRefStore::new(git_dir, format);
2218    let db = FileObjectDatabase::from_git_dir(git_dir, format);
2219    let head_symref = head_symref_target(&store)?;
2220
2221    // Build the (name -> oid, symref) list in git's advertisement order: HEAD
2222    // first (when present), then the sorted ref list from `for-each-ref`.
2223    let mut entries: Vec<(String, ObjectId, Option<String>)> = Vec::new();
2224    if let Some(target) = store.read_ref("HEAD")? {
2225        let reference = Ref {
2226            name: "HEAD".to_string(),
2227            target,
2228        };
2229        if let Some((oid, _)) = resolve_for_each_ref_target(&store, &reference)? {
2230            entries.push(("HEAD".to_string(), oid, head_symref.clone()));
2231        } else if request.unborn && lsrefs_unborn_config(config) != LsRefsUnbornConfig::Ignore {
2232            // An unborn HEAD (points at a not-yet-created branch) is reported as
2233            // an `unborn` record carrying its symref-target.
2234            entries.push((
2235                "HEAD".to_string(),
2236                ObjectId::null(format),
2237                head_symref.clone(),
2238            ));
2239        }
2240    }
2241    for reference in store.list_refs()? {
2242        let name = reference.name.clone();
2243        let Some((oid, symref)) = resolve_for_each_ref_target(&store, &reference)? else {
2244            continue;
2245        };
2246        entries.push((name, oid, symref));
2247    }
2248
2249    let matches_prefix = |name: &str| -> bool {
2250        if request.ref_prefixes.is_empty() {
2251            return true;
2252        }
2253        request
2254            .ref_prefixes
2255            .iter()
2256            .any(|prefix| name.starts_with(prefix.as_str()))
2257    };
2258
2259    let mut records = Vec::new();
2260    for (name, oid, symref) in entries {
2261        if !matches_prefix(&name) {
2262            continue;
2263        }
2264        // Unborn HEAD: only the all-zero placeholder reaches here with `unborn`.
2265        if name == "HEAD" && oid == ObjectId::null(format) {
2266            records.push(ProtocolV2LsRefsRecord::Unborn {
2267                name,
2268                symref_target: if request.symrefs { symref } else { None },
2269                attributes: Vec::new(),
2270            });
2271            continue;
2272        }
2273        let peeled = if request.peel {
2274            let object = db.read_object(&oid)?;
2275            if object.object_type == ObjectType::Tag {
2276                Some(sley_rev::peel_tags(&db, format, &oid)?)
2277            } else {
2278                None
2279            }
2280        } else {
2281            None
2282        };
2283        let symref_target = if request.symrefs { symref } else { None };
2284        records.push(ProtocolV2LsRefsRecord::Ref(ProtocolV2LsRefsRef {
2285            oid,
2286            name,
2287            peeled,
2288            symref_target,
2289            attributes: Vec::new(),
2290        }));
2291    }
2292    Ok(records)
2293}
2294
2295/// Chunk a raw packfile into sideband channel-1 (`SideBandChannel::Data`)
2296/// pkt-lines for the v2 fetch `packfile` section, matching the upstream
2297/// `0001`-prefixed framing. Each chunk carries at most
2298/// `PKT_LINE_MAX_PAYLOAD_LEN - 1` packfile bytes (the leading byte is the
2299/// channel marker).
2300fn packfile_section_lines(pack: &[u8]) -> Vec<Vec<u8>> {
2301    let chunk = PKT_LINE_MAX_PAYLOAD_LEN - 1;
2302    let mut lines = Vec::new();
2303    for slice in pack.chunks(chunk) {
2304        let mut payload = Vec::with_capacity(slice.len() + 1);
2305        payload.push(1u8); // SideBandChannel::Data
2306        payload.extend_from_slice(slice);
2307        lines.push(payload);
2308    }
2309    lines
2310}
2311
2312/// Build the protocol-v2 `fetch` response sections for a request against the
2313/// repository at `git_dir`. Mirrors `upload-pack.c::upload_pack_v2`'s
2314/// stateless single-round behavior: the client always sends `done` (the v2
2315/// clone/fetch path negotiates haves up front and finishes with `done`), so the
2316/// acknowledgments section is omitted and the response is just the packfile.
2317fn local_fetch_v2_sections(
2318    git_dir: &Path,
2319    format: ObjectFormat,
2320    request: &ProtocolV2FetchRequest,
2321) -> Result<Vec<ProtocolV2FetchResponseSection>> {
2322    let db = FileObjectDatabase::from_git_dir(git_dir, format);
2323
2324    let mut sections = Vec::new();
2325
2326    // Acknowledgments: per gitprotocol-v2, when the client sends `done` the
2327    // acknowledgments section MUST be omitted. Without `done` (multi-round
2328    // negotiation) we answer NAK/ACK for the haves we have in common; the v2
2329    // file:// client always finishes with `done` so this branch is the
2330    // negotiation fallback.
2331    if !request.done {
2332        let mut acks: Vec<ProtocolV2FetchAcknowledgment> = Vec::new();
2333        for have in &request.haves {
2334            if db.contains(have)? {
2335                acks.push(ProtocolV2FetchAcknowledgment::Ack(*have));
2336            }
2337        }
2338        if acks.is_empty() {
2339            acks.push(ProtocolV2FetchAcknowledgment::Nak);
2340        }
2341        let ready = acks
2342            .iter()
2343            .any(|ack| matches!(ack, ProtocolV2FetchAcknowledgment::Ack(_)));
2344        if ready {
2345            acks.push(ProtocolV2FetchAcknowledgment::Ready);
2346        }
2347        sections.push(ProtocolV2FetchResponseSection::Acknowledgments(acks));
2348        // Without a common commit, stop after acknowledgments so the client can
2349        // send its next batch. `wait-for-done` also stops after `ready`: the
2350        // client explicitly asked the server not to start the pack until a
2351        // subsequent request carries `done`.
2352        if !ready || request.wait_for_done {
2353            return Ok(sections);
2354        }
2355    }
2356
2357    // Wanted-refs: resolve each `want-ref <name>` to its current oid.
2358    if !request.want_refs.is_empty() {
2359        let store = FileRefStore::new(git_dir, format);
2360        let mut wanted = Vec::new();
2361        for name in &request.want_refs {
2362            let reference = Ref {
2363                name: name.clone(),
2364                target: store
2365                    .read_ref(name)?
2366                    .ok_or_else(|| GitError::not_found(format!("want-ref {name}")))?,
2367            };
2368            let (oid, _) = resolve_for_each_ref_target(&store, &reference)?
2369                .ok_or_else(|| GitError::not_found(format!("want-ref {name}")))?;
2370            wanted.push(sley_protocol::ProtocolV2FetchWantedRef {
2371                oid,
2372                name: name.clone(),
2373            });
2374        }
2375        sections.push(ProtocolV2FetchResponseSection::WantedRefs(wanted));
2376    }
2377
2378    // Resolve want-refs into concrete wants for the pack walk.
2379    let mut wants: Vec<ObjectId> = request.wants.clone();
2380    if !request.want_refs.is_empty()
2381        && let Some(ProtocolV2FetchResponseSection::WantedRefs(wanted)) = sections
2382            .iter()
2383            .find(|s| matches!(s, ProtocolV2FetchResponseSection::WantedRefs(_)))
2384    {
2385        for w in wanted {
2386            wants.push(w.oid);
2387        }
2388    }
2389    // A capability-only fetch command (for example the upstream
2390    // `packfile-uris` validation probe) has no pack request to answer. Git
2391    // accepts the command and emits no response section after advertising its
2392    // capabilities; emitting an empty `packfile` section is observably
2393    // different and can also corrupt a surrounding TAP stream.
2394    if wants.is_empty() {
2395        return Ok(sections);
2396    }
2397
2398    // Apply upload-pack's shallow request before constructing the pack. The
2399    // HTTP server reaches this same in-process path, so rejecting an empty
2400    // `deepen-since` selection and cutting the pack at the computed boundary
2401    // must happen here rather than in a transport-specific client.
2402    let deepen_plan = if request.deepen_since.is_some() || !request.deepen_not.is_empty() {
2403        let advertisements = local_fetch_advertisements(git_dir, format)?;
2404        let mut deepen_not = Vec::with_capacity(request.deepen_not.len());
2405        for name in &request.deepen_not {
2406            let advertisement = advertisements
2407                .iter()
2408                .find(|advertisement| {
2409                    advertisement.name == *name
2410                        || advertisement.name == format!("refs/tags/{name}")
2411                        || advertisement.name == format!("refs/heads/{name}")
2412                        || advertisement.name == format!("refs/{name}")
2413                })
2414                .ok_or_else(|| {
2415                    GitError::Command(format!("git upload-pack: deepen-not is not a ref: {name}"))
2416                })?;
2417            deepen_not.push(advertisement.oid);
2418        }
2419        let since = request
2420            .deepen_since
2421            .map(|value| {
2422                i64::try_from(value).map_err(|_| {
2423                    GitError::InvalidFormat(format!("invalid deepen-since timestamp {value}"))
2424                })
2425            })
2426            .transpose()?;
2427        Some(compute_local_deepen_by_rev_list(
2428            &db,
2429            format,
2430            &wants,
2431            request.shallow.clone(),
2432            since,
2433            &deepen_not,
2434        )?)
2435    } else if let Some(depth) = request.deepen {
2436        Some(compute_local_deepen(
2437            &db,
2438            format,
2439            &wants,
2440            request.shallow.clone(),
2441            depth,
2442            request.deepen_relative,
2443        )?)
2444    } else {
2445        None
2446    };
2447
2448    // Shallow-info: when the served repository is itself shallow and the client
2449    // did not request any deepening, report the shallow boundary commits that are
2450    // reachable from the wants (upstream `send_shallow_info`, which does an
2451    // implicit infinite-depth deepen on any fetch from a shallow repository). The
2452    // client uses these `shallow` lines to detect a shallow source — in
2453    // particular `git clone --reject-shallow` dies when they are present. The
2454    // section must precede the packfile section per gitprotocol-v2.
2455    let request_is_deepening = request.deepen.is_some()
2456        || request.deepen_since.is_some()
2457        || !request.deepen_not.is_empty();
2458    if !request_is_deepening {
2459        let server_shallow = crate::shallow::read_shallow(git_dir, format)?;
2460        if !server_shallow.is_empty() {
2461            let reachable = collect_reachable_object_ids(&db, format, wants.clone())?;
2462            let shallow_lines: Vec<ProtocolV2FetchShallowInfo> = server_shallow
2463                .iter()
2464                .filter(|oid| reachable.contains(*oid))
2465                .map(|oid| ProtocolV2FetchShallowInfo::Shallow(*oid))
2466                .collect();
2467            if !shallow_lines.is_empty() {
2468                sections.push(ProtocolV2FetchResponseSection::ShallowInfo(shallow_lines));
2469            }
2470        }
2471    } else if let Some(plan) = deepen_plan.as_ref()
2472        && !plan.shallow_info.is_empty()
2473    {
2474        sections.push(ProtocolV2FetchResponseSection::ShallowInfo(
2475            plan.shallow_info.clone(),
2476        ));
2477    }
2478
2479    // Packfile section: build the reachable pack excluding the client's haves.
2480    let mut known_haves: Vec<ObjectId> = Vec::new();
2481    for have in &request.haves {
2482        if db.contains(have)? {
2483            known_haves.push(*have);
2484        }
2485    }
2486    let mut excluded = collect_reachable_object_ids(&db, format, known_haves)?;
2487    if let Some(plan) = deepen_plan {
2488        wants.extend(plan.extra_wants);
2489        excluded.extend(plan.excluded);
2490    }
2491    // Honor a partial-clone `filter` (blob:none / blob:limit=<n> / tree:<depth>):
2492    // upstream upload-pack applies the filter to the objects it packs. Without
2493    // this, a `--filter=blob:limit=0` clone would receive every blob and the
2494    // resulting "partial" clone would not actually be partial.
2495    let filter = request
2496        .filter
2497        .as_deref()
2498        .and_then(crate::pack_filter_from_spec);
2499    let pack = if filter.is_some() {
2500        build_reachable_pack_filtered(&db, format, wants, &excluded, filter)?
2501    } else {
2502        build_reachable_pack(&db, format, wants, &excluded)?
2503    }
2504    .map(|pack| pack.pack)
2505    .unwrap_or_default();
2506
2507    sections.push(ProtocolV2FetchResponseSection::Packfile(
2508        packfile_section_lines(&pack),
2509    ));
2510    Ok(sections)
2511}
2512
2513/// Serve a protocol-v2 upload-pack session over `reader`/`writer` for the
2514/// repository at `git_dir`. Writes the capability advertisement, then loops
2515/// reading `command=` requests (`ls-refs` / `fetch`) until the client closes
2516/// the connection (EOF). Mirrors `upload-pack.c::upload_pack_v2` driven by
2517/// `serve.c`.
2518/// Respond to a `command=bundle-uri` request by writing every `bundle.*` config
2519/// variable as a `key=value` packet line, terminated by a flush. Mirrors
2520/// upstream `bundle_uri_command` / `config_to_packet_line`: the section name is
2521/// lowercased, the subsection keeps its case, and the variable name is lowercased
2522/// (git's config normalization), e.g. `bundle.everything.uri=<uri>`.
2523fn write_bundle_uri_command_response(
2524    config: &GitConfig,
2525    writer: &mut impl std::io::Write,
2526) -> Result<()> {
2527    for section in &config.sections {
2528        if !section.name.eq_ignore_ascii_case("bundle") {
2529            continue;
2530        }
2531        for entry in &section.entries {
2532            let Some(value) = entry.value.as_deref() else {
2533                continue;
2534            };
2535            let key = match &section.subsection {
2536                Some(subsection) => {
2537                    format!("bundle.{subsection}.{}", entry.key.to_ascii_lowercase())
2538                }
2539                None => format!("bundle.{}", entry.key.to_ascii_lowercase()),
2540            };
2541            write_pkt_line_payload(writer, format!("{key}={value}").as_bytes())?;
2542        }
2543    }
2544    write_pkt_line_frame(writer, &PktLineFrame::Flush)?;
2545    Ok(())
2546}
2547
2548pub fn serve_upload_pack_v2(
2549    git_dir: &Path,
2550    format: ObjectFormat,
2551    reader: &mut impl std::io::Read,
2552    writer: &mut impl std::io::Write,
2553) -> Result<()> {
2554    let config = sley_config::read_repo_config(git_dir, None).unwrap_or_default();
2555    serve_upload_pack_v2_with_config(git_dir, format, &config, reader, writer)
2556}
2557
2558pub fn serve_upload_pack_v2_with_config(
2559    git_dir: &Path,
2560    format: ObjectFormat,
2561    config: &GitConfig,
2562    reader: &mut impl std::io::Read,
2563    writer: &mut impl std::io::Write,
2564) -> Result<()> {
2565    serve_upload_pack_v2_inner(git_dir, format, config, reader, writer, true)
2566}
2567
2568/// Serve a protocol-v2 stateless RPC request without re-advertising
2569/// capabilities. Smart HTTP performs capability discovery in its preceding
2570/// `info/refs` GET, so each upload-pack POST begins directly with the command
2571/// response, matching `git upload-pack --stateless-rpc`.
2572pub fn serve_upload_pack_v2_stateless_with_config(
2573    git_dir: &Path,
2574    format: ObjectFormat,
2575    config: &GitConfig,
2576    reader: &mut impl std::io::Read,
2577    writer: &mut impl std::io::Write,
2578) -> Result<()> {
2579    serve_upload_pack_v2_inner(git_dir, format, config, reader, writer, false)
2580}
2581
2582fn serve_upload_pack_v2_inner(
2583    git_dir: &Path,
2584    format: ObjectFormat,
2585    config: &GitConfig,
2586    reader: &mut impl std::io::Read,
2587    writer: &mut impl std::io::Write,
2588    advertise: bool,
2589) -> Result<()> {
2590    let handshake = TransportHandshake {
2591        protocol: ProtocolVersion::V2,
2592        capabilities: upload_pack_v2_capabilities(format, config)?,
2593    };
2594    if advertise {
2595        write_protocol_v2_advertisement(writer, &handshake)?;
2596        writer.flush()?;
2597    }
2598
2599    // EOF / a lone flush after the advertisement ends the session: the client
2600    // disconnected (e.g. `ls-remote` reads the refs and leaves). Malformed
2601    // requests after a command line are protocol violations and must fail
2602    // visibly instead of being treated as a clean disconnect.
2603    loop {
2604        let request = match read_protocol_v2_command_request(reader) {
2605            Ok(request) => request,
2606            Err(GitError::InvalidFormat(message))
2607                if message == "pkt-line stream ended before control packet"
2608                    || message == "protocol v2 command request must start with a command line" =>
2609            {
2610                break;
2611            }
2612            Err(err) => return Err(err),
2613        };
2614        // `command=bundle-uri` is not part of the fetch/ls-refs classification;
2615        // handle it directly by streaming the repository's `bundle.*` config as
2616        // `key=value` packet lines (upstream `bundle_uri_command`).
2617        if request.command == "bundle-uri" {
2618            write_bundle_uri_command_response(config, writer)?;
2619            writer.flush()?;
2620            continue;
2621        }
2622        match classify_protocol_v2_command_request(&handshake, format, &request)? {
2623            sley_protocol::ProtocolV2Command::LsRefs(ls_refs) => {
2624                let records = local_ls_refs_v2_records(git_dir, format, &ls_refs, config)?;
2625                write_protocol_v2_ls_refs_response(writer, &records)?;
2626                writer.flush()?;
2627            }
2628            sley_protocol::ProtocolV2Command::Fetch(fetch) => {
2629                let sections = local_fetch_v2_sections(git_dir, format, &fetch)?;
2630                if !sections.is_empty() {
2631                    write_protocol_v2_fetch_response(writer, &sections)?;
2632                    writer.flush()?;
2633                }
2634            }
2635            sley_protocol::ProtocolV2Command::ObjectInfo(_)
2636            | sley_protocol::ProtocolV2Command::Unknown(_) => {
2637                return Err(GitError::InvalidFormat(format!(
2638                    "unsupported protocol v2 command {}",
2639                    request.command
2640                )));
2641            }
2642        }
2643    }
2644    Ok(())
2645}
2646
2647#[cfg(test)]
2648mod tests {
2649    use super::*;
2650    use sley_object::{BString, EncodedObject, Tree, TreeEntry};
2651    use sley_odb::ObjectWriter;
2652
2653    #[test]
2654    fn filtered_clone_and_exact_checkout_hydration_install_separate_promisor_packs() {
2655        for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] {
2656            let root = unique_local_test_dir("split-partial-clone");
2657            let remote_git = root.join("remote.git");
2658            let client_git = root.join("client.git");
2659            for git_dir in [&remote_git, &client_git] {
2660                fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
2661                fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
2662                    .expect("test repository HEAD");
2663            }
2664            let remote_db = FileObjectDatabase::from_git_dir(&remote_git, format);
2665            let blob = write_test_object(
2666                &remote_db,
2667                &EncodedObject::new(ObjectType::Blob, b"checkout payload\n".to_vec()),
2668            );
2669            let tree = write_test_object(&remote_db, &test_tree(&[(0o100644, b"file", blob)]));
2670            let commit = write_test_object(&remote_db, &test_commit(tree, &[], b"tip\n"));
2671
2672            install_fetch_pack_via_local_upload_pack_with_promisor_decision_into(
2673                &client_git,
2674                &client_git,
2675                &remote_git,
2676                format,
2677                vec![commit],
2678                None,
2679                true,
2680                false,
2681                Some(sley_odb::PackObjectFilter::BlobNone),
2682                None,
2683                false,
2684                Some(1),
2685                &crate::PromisorRemoteDecision::default(),
2686                LocalFetchPackRequestMode::Traversal,
2687            )
2688            .expect("install filtered refs pack");
2689            let client_db = FileObjectDatabase::from_git_dir(&client_git, format);
2690            assert!(client_db.contains(&commit).expect("read filtered commit"));
2691            assert!(client_db.contains(&tree).expect("read filtered tree"));
2692            assert!(!client_db.contains(&blob).expect("check filtered blob"));
2693
2694            install_fetch_pack_via_local_upload_pack(
2695                &client_git,
2696                &remote_git,
2697                format,
2698                vec![blob],
2699                None,
2700                true,
2701                false,
2702                None,
2703                None,
2704                false,
2705                Some(1),
2706            )
2707            .expect("install exact checkout blob pack");
2708            assert!(client_db.contains(&blob).expect("read hydrated blob"));
2709            let promisor_packs = fs::read_dir(client_git.join("objects/pack"))
2710                .expect("read client packs")
2711                .filter_map(std::result::Result::ok)
2712                .filter(|entry| {
2713                    entry
2714                        .path()
2715                        .extension()
2716                        .is_some_and(|ext| ext == "promisor")
2717                })
2718                .count();
2719            assert_eq!(promisor_packs, 2);
2720            fs::remove_dir_all(root).expect("remove test repositories");
2721        }
2722    }
2723
2724    #[test]
2725    fn multi_round_negotiation_resets_in_vain_on_unrelated_ack() {
2726        let git_dir = unique_local_test_dir("negotiation-in-vain-reset");
2727        fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
2728        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n").expect("test repository HEAD");
2729        let format = ObjectFormat::Sha1;
2730        let db = FileObjectDatabase::from_git_dir(&git_dir, format);
2731        let tree_oid = write_test_object(&db, &test_tree(&[]));
2732        let side_common = write_test_object(&db, &test_commit(tree_oid, &[], b"side\n"));
2733        let main_common = write_test_object(&db, &test_commit(tree_oid, &[], b"main\n"));
2734        let want = write_test_object(&db, &test_commit(tree_oid, &[main_common], b"want\n"));
2735
2736        let missing = |value: usize| {
2737            ObjectId::from_hex(format, &format!("{value:040x}")).expect("synthetic negotiation oid")
2738        };
2739        let mut haves = (1..=255).map(missing).collect::<Vec<_>>();
2740        haves.push(side_common);
2741        haves.extend((256..=510).map(missing));
2742        haves.push(main_common);
2743
2744        let outcome = negotiate_local_upload_pack(&db, format, &[want], haves)
2745            .expect("multi-round negotiation");
2746        assert_eq!(outcome.total_rounds, 6);
2747        assert_eq!(outcome.common_haves, vec![side_common, main_common]);
2748
2749        fs::remove_dir_all(git_dir).expect("remove test repository");
2750    }
2751
2752    #[test]
2753    fn multi_round_negotiation_does_not_give_up_before_first_ack() {
2754        let git_dir = unique_local_test_dir("negotiation-first-ack");
2755        fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
2756        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n").expect("test repository HEAD");
2757        let format = ObjectFormat::Sha1;
2758        let db = FileObjectDatabase::from_git_dir(&git_dir, format);
2759        let tree_oid = write_test_object(&db, &test_tree(&[]));
2760        let common = write_test_object(&db, &test_commit(tree_oid, &[], b"common\n"));
2761        let want = write_test_object(&db, &test_commit(tree_oid, &[common], b"want\n"));
2762        let mut haves = (1..=496)
2763            .map(|value| {
2764                ObjectId::from_hex(format, &format!("{value:040x}"))
2765                    .expect("synthetic negotiation oid")
2766            })
2767            .collect::<Vec<_>>();
2768        haves.push(common);
2769
2770        let outcome = negotiate_local_upload_pack(&db, format, &[want], haves)
2771            .expect("multi-round negotiation");
2772        assert_eq!(outcome.total_rounds, 6);
2773        assert_eq!(outcome.common_haves, vec![common]);
2774
2775        fs::remove_dir_all(git_dir).expect("remove test repository");
2776    }
2777
2778    #[test]
2779    fn negotiation_haves_stop_behind_remote_advertised_commit_tips() {
2780        let root = unique_local_test_dir("negotiation-advertised-tip");
2781        let remote_git = root.join("remote.git");
2782        let client_git = root.join("client.git");
2783        for git_dir in [&remote_git, &client_git] {
2784            fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
2785            fs::create_dir_all(git_dir.join("refs/heads")).expect("test repository refs");
2786            fs::create_dir_all(git_dir.join("refs/tags")).expect("test repository tags");
2787            fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
2788                .expect("test repository HEAD");
2789        }
2790
2791        let format = ObjectFormat::Sha1;
2792        let client_db = FileObjectDatabase::from_git_dir(&client_git, format);
2793        let remote_db = FileObjectDatabase::from_git_dir(&remote_git, format);
2794        let tree = test_tree(&[]);
2795        let tree_oid = write_test_object(&client_db, &tree);
2796        write_test_object(&remote_db, &tree);
2797        let root_commit = test_commit(tree_oid, &[], b"root\n");
2798        let root_oid = write_test_object(&client_db, &root_commit);
2799        write_test_object(&remote_db, &root_commit);
2800        let common_commit = test_commit(tree_oid, &[root_oid], b"common\n");
2801        let common_oid = write_test_object(&client_db, &common_commit);
2802        write_test_object(&remote_db, &common_commit);
2803        let client_commit = test_commit(tree_oid, &[common_oid], b"client\n");
2804        let client_oid = write_test_object(&client_db, &client_commit);
2805        let remote_commit = test_commit(tree_oid, &[common_oid], b"remote\n");
2806        let remote_oid = write_test_object(&remote_db, &remote_commit);
2807        fs::write(
2808            client_git.join("refs/heads/main"),
2809            format!("{client_oid}\n"),
2810        )
2811        .expect("client main ref");
2812        fs::write(
2813            client_git.join("refs/tags/common"),
2814            format!("{common_oid}\n"),
2815        )
2816        .expect("client common tag");
2817        fs::write(
2818            remote_git.join("refs/heads/main"),
2819            format!("{remote_oid}\n"),
2820        )
2821        .expect("remote main ref");
2822        fs::write(
2823            remote_git.join("refs/tags/common"),
2824            format!("{common_oid}\n"),
2825        )
2826        .expect("remote common tag");
2827
2828        let haves =
2829            local_negotiation_have_oids_pruned_by_remote_tips(&client_git, &remote_git, format)
2830                .expect("plan negotiation haves");
2831        assert!(haves.contains(&client_oid));
2832        assert!(haves.contains(&common_oid));
2833        assert!(!haves.contains(&root_oid));
2834
2835        fs::remove_dir_all(root).expect("remove test repositories");
2836    }
2837
2838    #[test]
2839    fn negotiation_ignores_advertised_tags_with_promised_missing_targets() {
2840        for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] {
2841            let root = unique_local_test_dir("negotiation-promised-tag-target");
2842            let source_git = root.join("source.git");
2843            let remote_git = root.join("remote.git");
2844            let client_git = root.join("client.git");
2845            for git_dir in [&source_git, &remote_git, &client_git] {
2846                fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
2847                fs::create_dir_all(git_dir.join("refs/heads")).expect("test repository heads");
2848                fs::create_dir_all(git_dir.join("refs/tags")).expect("test repository tags");
2849                fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
2850                    .expect("test repository HEAD");
2851            }
2852
2853            let source_db = FileObjectDatabase::from_git_dir(&source_git, format);
2854            let promised_blob = write_test_object(
2855                &source_db,
2856                &EncodedObject::new(ObjectType::Blob, b"promised tag target\n".to_vec()),
2857            );
2858            let tree = write_test_object(&source_db, &test_tree(&[]));
2859            let commit = write_test_object(&source_db, &test_commit(tree, &[], b"main\n"));
2860            let tag = write_test_object(
2861                &source_db,
2862                &EncodedObject::new(
2863                    ObjectType::Tag,
2864                    Tag {
2865                        object: promised_blob,
2866                        object_type: ObjectType::Blob,
2867                        name: b"promised-blob".to_vec(),
2868                        tagger: None,
2869                        message: b"promised blob tag\n".to_vec(),
2870                        raw_body: None,
2871                    }
2872                    .write(),
2873                ),
2874            );
2875
2876            sley_odb::build_and_install_reachable_pack_filtered(
2877                &source_db,
2878                &FileObjectDatabase::from_git_dir(&remote_git, format),
2879                format,
2880                [commit, tag],
2881                &HashSet::new(),
2882                RawPackInstallOptions {
2883                    promisor: true,
2884                    ..Default::default()
2885                },
2886                Some(sley_odb::PackObjectFilter::BlobNone),
2887                None,
2888            )
2889            .expect("build partial remote")
2890            .expect("install partial remote pack");
2891            sley_odb::build_and_install_reachable_pack_filtered(
2892                &source_db,
2893                &FileObjectDatabase::from_git_dir(&client_git, format),
2894                format,
2895                [tag],
2896                &HashSet::new(),
2897                RawPackInstallOptions {
2898                    promisor: true,
2899                    ..Default::default()
2900                },
2901                Some(sley_odb::PackObjectFilter::BlobNone),
2902                None,
2903            )
2904            .expect("build partial client")
2905            .expect("install partial client pack");
2906            fs::write(
2907                remote_git.join("config"),
2908                b"[extensions]\n\tpartialClone = origin\n[remote \"origin\"]\n\tpromisor = true\n",
2909            )
2910            .expect("partial remote config");
2911            fs::write(
2912                client_git.join("config"),
2913                b"[extensions]\n\tpartialClone = origin\n[remote \"origin\"]\n\tpromisor = true\n",
2914            )
2915            .expect("partial client config");
2916            fs::write(remote_git.join("refs/heads/main"), format!("{commit}\n"))
2917                .expect("remote main ref");
2918            fs::write(
2919                remote_git.join("refs/tags/promised-blob"),
2920                format!("{tag}\n"),
2921            )
2922            .expect("remote tag ref");
2923            fs::write(
2924                client_git.join("refs/tags/promised-blob"),
2925                format!("{tag}\n"),
2926            )
2927            .expect("client tag ref");
2928
2929            let remote_db = FileObjectDatabase::from_git_dir(&remote_git, format)
2930                .with_promisor_remote_present(true);
2931            assert!(remote_db.contains(&commit).expect("remote commit lookup"));
2932            assert!(remote_db.contains(&tag).expect("remote tag lookup"));
2933            assert!(
2934                !remote_db
2935                    .contains(&promised_blob)
2936                    .expect("remote blob lookup")
2937            );
2938            assert!(remote_db.is_promised_object(&promised_blob));
2939            let client_db = FileObjectDatabase::from_git_dir(&client_git, format)
2940                .with_promisor_remote_present(true);
2941            assert!(client_db.contains(&tag).expect("client tag lookup"));
2942            assert!(
2943                !client_db
2944                    .contains(&promised_blob)
2945                    .expect("client blob lookup")
2946            );
2947            assert!(client_db.is_promised_object(&promised_blob));
2948
2949            install_fetch_pack_via_local_upload_pack(
2950                &client_git,
2951                &remote_git,
2952                format,
2953                vec![commit],
2954                None,
2955                false,
2956                false,
2957                None,
2958                None,
2959                false,
2960                None,
2961            )
2962            .expect("unrelated branch fetch ignores promised tag target");
2963            assert!(
2964                FileObjectDatabase::from_git_dir(&client_git, format)
2965                    .contains(&commit)
2966                    .expect("client commit lookup")
2967            );
2968
2969            fs::remove_dir_all(root).expect("remove test repositories");
2970        }
2971    }
2972
2973    #[test]
2974    fn receive_pack_advertises_no_thin_until_server_fixes_thin_packs() {
2975        let features = receive_pack_features(ObjectFormat::Sha1);
2976        assert!(features.no_thin);
2977
2978        let capabilities =
2979            encode_receive_pack_features(&features).expect("test operation should succeed");
2980        assert!(
2981            capabilities
2982                .iter()
2983                .any(|capability| capability.name == "no-thin")
2984        );
2985    }
2986
2987    #[test]
2988    fn protocol_v2_local_fetch_resolves_want_ref_and_installs_tip() {
2989        let root = unique_local_test_dir("protocol-v2-want-ref");
2990        let remote_git = root.join("remote.git");
2991        let client_git = root.join("client.git");
2992        for git_dir in [&remote_git, &client_git] {
2993            fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
2994            fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
2995                .expect("test repository HEAD");
2996        }
2997        fs::create_dir_all(remote_git.join("refs/heads")).expect("test repository refs");
2998        fs::write(
2999            remote_git.join("config"),
3000            b"[uploadpack]\n\tallowRefInWant = true\n",
3001        )
3002        .expect("test repository config");
3003
3004        let format = ObjectFormat::Sha1;
3005        let remote_db = FileObjectDatabase::from_git_dir(&remote_git, format);
3006        let blob_oid = write_test_object(
3007            &remote_db,
3008            &EncodedObject::new(ObjectType::Blob, b"wanted\n".to_vec()),
3009        );
3010        let tree_oid = write_test_object(&remote_db, &test_tree(&[(0o100644, b"file", blob_oid)]));
3011        let commit_oid = write_test_object(&remote_db, &test_commit(tree_oid, &[], b"tip\n"));
3012        fs::write(
3013            remote_git.join("refs/heads/main"),
3014            format!("{commit_oid}\n"),
3015        )
3016        .expect("test repository main ref");
3017
3018        let outcome = install_fetch_pack_via_local_protocol_v2(LocalProtocolV2FetchRequest {
3019            git_dir: &client_git,
3020            destination_git_dir: &client_git,
3021            remote_git_dir: &remote_git,
3022            format,
3023            wants: Vec::new(),
3024            want_refs: vec!["refs/heads/main".into()],
3025            haves: Some(Vec::new()),
3026        })
3027        .expect("protocol-v2 want-ref fetch");
3028
3029        assert_eq!(outcome.wanted_refs.len(), 1);
3030        assert_eq!(outcome.wanted_refs[0].name, "refs/heads/main");
3031        assert_eq!(outcome.wanted_refs[0].oid, commit_oid);
3032        assert!(
3033            FileObjectDatabase::from_git_dir(&client_git, format)
3034                .contains(&commit_oid)
3035                .expect("client object lookup")
3036        );
3037        fs::remove_dir_all(root).expect("remove test repositories");
3038    }
3039
3040    #[test]
3041    fn protocol_v2_upload_pack_rejects_deepen_since_after_every_commit() {
3042        let git_dir = unique_local_test_dir("protocol-v2-empty-deepen-since");
3043        fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
3044        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n").expect("test repository HEAD");
3045
3046        let format = ObjectFormat::Sha1;
3047        let db = FileObjectDatabase::from_git_dir(&git_dir, format);
3048        let tree_oid = write_test_object(&db, &test_tree(&[]));
3049        let commit_oid = write_test_object(&db, &test_commit(tree_oid, &[], b"tip\n"));
3050        let error = local_fetch_v2_sections(
3051            &git_dir,
3052            format,
3053            &ProtocolV2FetchRequest {
3054                wants: vec![commit_oid],
3055                deepen_since: Some(1),
3056                done: true,
3057                ..ProtocolV2FetchRequest::default()
3058            },
3059        )
3060        .expect_err("a cutoff after every commit must fail");
3061
3062        assert!(
3063            error
3064                .to_string()
3065                .contains("no commits selected for shallow requests")
3066        );
3067        fs::remove_dir_all(git_dir).expect("remove test repository");
3068    }
3069
3070    #[test]
3071    fn protocol_v2_upload_pack_negotiates_ready_before_pack() {
3072        let git_dir = unique_local_test_dir("protocol-v2-negotiation");
3073        fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
3074        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n").expect("test repository HEAD");
3075
3076        let format = ObjectFormat::Sha1;
3077        let db = FileObjectDatabase::from_git_dir(&git_dir, format);
3078        let tree_oid = write_test_object(&db, &test_tree(&[]));
3079        let base = write_test_object(&db, &test_commit(tree_oid, &[], b"base\n"));
3080        let tip = write_test_object(&db, &test_commit(tree_oid, &[base], b"tip\n"));
3081        let sections = local_fetch_v2_sections(
3082            &git_dir,
3083            format,
3084            &ProtocolV2FetchRequest {
3085                wants: vec![tip],
3086                haves: vec![base],
3087                done: false,
3088                ..ProtocolV2FetchRequest::default()
3089            },
3090        )
3091        .expect("common have makes the server ready");
3092        assert_eq!(
3093            sections.first(),
3094            Some(&ProtocolV2FetchResponseSection::Acknowledgments(vec![
3095                ProtocolV2FetchAcknowledgment::Ack(base),
3096                ProtocolV2FetchAcknowledgment::Ready,
3097            ]))
3098        );
3099        assert!(matches!(
3100            sections.last(),
3101            Some(ProtocolV2FetchResponseSection::Packfile(_))
3102        ));
3103
3104        let unknown = ObjectId::from_hex(format, "1111111111111111111111111111111111111111")
3105            .expect("test object id");
3106        let sections = local_fetch_v2_sections(
3107            &git_dir,
3108            format,
3109            &ProtocolV2FetchRequest {
3110                wants: vec![tip],
3111                haves: vec![unknown],
3112                done: false,
3113                ..ProtocolV2FetchRequest::default()
3114            },
3115        )
3116        .expect("unknown have continues negotiation");
3117        assert_eq!(
3118            sections,
3119            vec![ProtocolV2FetchResponseSection::Acknowledgments(vec![
3120                ProtocolV2FetchAcknowledgment::Nak,
3121            ])]
3122        );
3123
3124        fs::remove_dir_all(git_dir).expect("remove test repository");
3125    }
3126
3127    #[test]
3128    fn protocol_v2_fetch_without_wants_emits_no_empty_packfile_section() {
3129        let git_dir = unique_local_test_dir("protocol-v2-no-wants");
3130        fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
3131        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n").expect("test repository HEAD");
3132
3133        let sections = local_fetch_v2_sections(
3134            &git_dir,
3135            ObjectFormat::Sha1,
3136            &ProtocolV2FetchRequest {
3137                done: true,
3138                packfile_uris: Some("https".into()),
3139                ..ProtocolV2FetchRequest::default()
3140            },
3141        )
3142        .expect("capability-only fetch request");
3143        assert!(sections.is_empty());
3144
3145        let config = GitConfig::parse(b"[uploadpack]\n\tblobpackfileuri = anything\n")
3146            .expect("packfile-uri config");
3147        let request = ProtocolV2FetchRequest {
3148            done: true,
3149            packfile_uris: Some("https".into()),
3150            ..ProtocolV2FetchRequest::default()
3151        };
3152        let mut request_bytes = Vec::new();
3153        write_protocol_v2_fetch_request(&mut request_bytes, &request)
3154            .expect("encode capability-only request");
3155
3156        let mut stateless_response = Vec::new();
3157        serve_upload_pack_v2_stateless_with_config(
3158            &git_dir,
3159            ObjectFormat::Sha1,
3160            &config,
3161            &mut request_bytes.as_slice(),
3162            &mut stateless_response,
3163        )
3164        .expect("serve stateless capability-only request");
3165        assert!(stateless_response.is_empty());
3166
3167        let mut long_lived_response = Vec::new();
3168        serve_upload_pack_v2_with_config(
3169            &git_dir,
3170            ObjectFormat::Sha1,
3171            &config,
3172            &mut request_bytes.as_slice(),
3173            &mut long_lived_response,
3174        )
3175        .expect("serve long-lived capability-only request");
3176        let handshake = TransportHandshake {
3177            protocol: ProtocolVersion::V2,
3178            capabilities: upload_pack_v2_capabilities(ObjectFormat::Sha1, &config)
3179                .expect("test capabilities"),
3180        };
3181        let mut advertisement = Vec::new();
3182        write_protocol_v2_advertisement(&mut advertisement, &handshake)
3183            .expect("encode test advertisement");
3184        assert_eq!(long_lived_response, advertisement);
3185        fs::remove_dir_all(git_dir).expect("remove test repository");
3186    }
3187
3188    #[test]
3189    fn protocol_v2_stateless_upload_pack_does_not_readvertise() {
3190        let git_dir = unique_local_test_dir("protocol-v2-stateless");
3191        fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
3192        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n").expect("test repository HEAD");
3193
3194        let format = ObjectFormat::Sha1;
3195        let db = FileObjectDatabase::from_git_dir(&git_dir, format);
3196        let tree_oid = write_test_object(&db, &test_tree(&[]));
3197        let tip = write_test_object(&db, &test_commit(tree_oid, &[], b"tip\n"));
3198        let mut request = Vec::new();
3199        write_protocol_v2_fetch_request(
3200            &mut request,
3201            &ProtocolV2FetchRequest {
3202                wants: vec![tip],
3203                done: true,
3204                ..ProtocolV2FetchRequest::default()
3205            },
3206        )
3207        .expect("encode fetch request");
3208        let mut response = Vec::new();
3209        serve_upload_pack_v2_stateless_with_config(
3210            &git_dir,
3211            format,
3212            &GitConfig::default(),
3213            &mut request.as_slice(),
3214            &mut response,
3215        )
3216        .expect("serve stateless fetch");
3217
3218        assert_eq!(
3219            sley_protocol::read_pkt_line_frame(&mut response.as_slice())
3220                .expect("read response")
3221                .expect("response frame"),
3222            PktLineFrame::Data(b"packfile\n".to_vec())
3223        );
3224        fs::remove_dir_all(git_dir).expect("remove test repository");
3225    }
3226
3227    #[test]
3228    fn local_fetch_from_incomplete_remote_excludes_client_have_closure() {
3229        let root = unique_local_test_dir("incomplete-local-fetch");
3230        let base_git = root.join("base.git");
3231        let patch_git = root.join("patch.git");
3232        let user_git = root.join("user.git");
3233        let direct_git = root.join("direct.git");
3234        for git_dir in [&base_git, &patch_git, &user_git, &direct_git] {
3235            fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
3236            fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
3237                .expect("test operation should succeed");
3238        }
3239
3240        let format = ObjectFormat::Sha1;
3241        let base_db = FileObjectDatabase::from_git_dir(&base_git, format);
3242        let patch_db = FileObjectDatabase::from_git_dir(&patch_git, format);
3243
3244        let text_a = EncodedObject::new(ObjectType::Blob, b"a\nb\nc\nd\ne\nf\ng\nh\ni\n".to_vec());
3245        let text_a_oid = write_test_object(&base_db, &text_a);
3246        let side = EncodedObject::new(ObjectType::Blob, b"side\n".to_vec());
3247        let side_oid = write_test_object(&base_db, &side);
3248        let tree_a = test_tree(&[
3249            (0o100644, b"side", side_oid),
3250            (0o100644, b"text", text_a_oid),
3251        ]);
3252        let tree_a_oid = write_test_object(&base_db, &tree_a);
3253        let commit_a = test_commit(tree_a_oid, &[], b"A\n");
3254        let commit_a_oid = write_test_object(&base_db, &commit_a);
3255
3256        let text_b =
3257            EncodedObject::new(ObjectType::Blob, b"a\nb\nc\nd\ne\nf\ng\nh\ni\nm\n".to_vec());
3258        let text_b_oid = write_test_object(&base_db, &text_b);
3259        let tree_b = test_tree(&[
3260            (0o100644, b"side", side_oid),
3261            (0o100644, b"text", text_b_oid),
3262        ]);
3263        let tree_b_oid = write_test_object(&base_db, &tree_b);
3264        let commit_b = test_commit(tree_b_oid, &[commit_a_oid], b"B\n");
3265        let commit_b_oid = write_test_object(&base_db, &commit_b);
3266
3267        let text_c = EncodedObject::new(
3268            ObjectType::Blob,
3269            b"a\nb\nc\nd\ne\nf\ng\nh\ni\nm\nq\n".to_vec(),
3270        );
3271        let text_c_oid = write_test_object(&patch_db, &text_c);
3272        let tree_c = test_tree(&[
3273            (0o100644, b"side", side_oid),
3274            (0o100644, b"text", text_c_oid),
3275        ]);
3276        let tree_c_oid = write_test_object(&patch_db, &tree_c);
3277        let commit_c = test_commit(tree_c_oid, &[commit_b_oid], b"C\n");
3278        let commit_c_oid = write_test_object(&patch_db, &commit_c);
3279        write_test_object(&patch_db, &tree_b);
3280        write_test_object(&patch_db, &commit_b);
3281        assert!(
3282            !patch_db
3283                .contains(&text_b_oid)
3284                .expect("test operation should succeed"),
3285            "patch repo must be missing the best delta base"
3286        );
3287
3288        install_fetch_pack_via_local_upload_pack(
3289            &user_git,
3290            &base_git,
3291            format,
3292            vec![commit_b_oid],
3293            None,
3294            false,
3295            false,
3296            None,
3297            None,
3298            false,
3299            None,
3300        )
3301        .expect("base fetch should succeed");
3302        assert!(
3303            FileObjectDatabase::from_git_dir(&user_git, format)
3304                .contains(&text_b_oid)
3305                .expect("test operation should succeed"),
3306            "user clone should have the missing base before fetching C"
3307        );
3308
3309        install_fetch_pack_via_local_upload_pack(
3310            &user_git,
3311            &patch_git,
3312            format,
3313            vec![commit_c_oid],
3314            None,
3315            false,
3316            false,
3317            None,
3318            None,
3319            false,
3320            None,
3321        )
3322        .expect("fetch from incomplete remote should succeed when client has the base");
3323        assert!(
3324            FileObjectDatabase::from_git_dir(&user_git, format)
3325                .contains(&commit_c_oid)
3326                .expect("test operation should succeed")
3327        );
3328
3329        let direct = install_fetch_pack_via_local_upload_pack(
3330            &direct_git,
3331            &patch_git,
3332            format,
3333            vec![commit_c_oid],
3334            None,
3335            false,
3336            false,
3337            None,
3338            None,
3339            false,
3340            None,
3341        );
3342        assert!(
3343            direct.is_err(),
3344            "direct fetch from the incomplete patch repo must still fail"
3345        );
3346
3347        fs::remove_dir_all(root).expect("test operation should succeed");
3348    }
3349
3350    #[test]
3351    fn direct_promisor_object_fetch_ignores_blob_none_for_explicit_blob() {
3352        let root = unique_local_test_dir("direct-promisor-object-fetch");
3353        let remote_git = root.join("remote.git");
3354        let client_git = root.join("client.git");
3355        for git_dir in [&remote_git, &client_git] {
3356            fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
3357            fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
3358                .expect("test operation should succeed");
3359        }
3360
3361        let format = ObjectFormat::Sha1;
3362        let remote_db = FileObjectDatabase::from_git_dir(&remote_git, format);
3363        let client_db = FileObjectDatabase::from_git_dir(&client_git, format);
3364
3365        let blob = EncodedObject::new(ObjectType::Blob, b"promised\n".to_vec());
3366        let blob_oid = write_test_object(&remote_db, &blob);
3367        let tree = test_tree(&[(0o100644, b"file.txt", blob_oid)]);
3368        let tree_oid = write_test_object(&remote_db, &tree);
3369        let commit = test_commit(tree_oid, &[], b"main\n");
3370        let commit_oid = write_test_object(&remote_db, &commit);
3371
3372        write_test_object(&client_db, &tree);
3373        write_test_object(&client_db, &commit);
3374        assert!(
3375            !client_db
3376                .contains(&blob_oid)
3377                .expect("test operation should succeed"),
3378            "client starts with the promised blob missing"
3379        );
3380
3381        install_fetch_pack_via_local_upload_pack(
3382            &client_git,
3383            &remote_git,
3384            format,
3385            vec![blob_oid],
3386            None,
3387            true,
3388            false,
3389            Some(sley_odb::PackObjectFilter::BlobNone),
3390            None,
3391            false,
3392            None,
3393        )
3394        .expect("direct promisor blob fetch must ignore blob:none for the explicit want");
3395
3396        assert!(
3397            FileObjectDatabase::from_git_dir(&client_git, format)
3398                .contains(&blob_oid)
3399                .expect("test operation should succeed"),
3400            "directly wanted promised blob should be installed"
3401        );
3402        assert_eq!(
3403            FileObjectDatabase::from_git_dir(&client_git, format)
3404                .read_object(&commit_oid)
3405                .expect("test operation should succeed")
3406                .object_type,
3407            ObjectType::Commit
3408        );
3409
3410        fs::remove_dir_all(root).expect("test operation should succeed");
3411    }
3412
3413    #[test]
3414    fn exact_object_hydration_uses_configured_local_promisors() {
3415        let root = unique_local_test_dir("exact-promisor-hydration");
3416        let client_git = root.join("client.git");
3417        let origin_git = root.join("origin.git");
3418        let lop_git = root.join("lop.git");
3419        for git_dir in [&client_git, &origin_git, &lop_git] {
3420            fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
3421            fs::create_dir_all(git_dir.join("refs")).expect("test repository refs");
3422            fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
3423                .expect("test repository HEAD");
3424        }
3425        fs::write(
3426            client_git.join("config"),
3427            format!(
3428                "[extensions]\n\tpartialClone = origin\n\
3429                 [remote \"origin\"]\n\tpromisor = true\n\turl = {}\n\
3430                 [remote \"lop\"]\n\tpromisor = true\n\turl = {}\n",
3431                origin_git.display(),
3432                lop_git.display()
3433            ),
3434        )
3435        .expect("client promisor config");
3436
3437        let format = ObjectFormat::Sha1;
3438        let blob = EncodedObject::new(ObjectType::Blob, b"lazy payload\n".to_vec());
3439        let blob_oid =
3440            write_test_object(&FileObjectDatabase::from_git_dir(&lop_git, format), &blob);
3441        let hydrated =
3442            hydrate_objects_from_local_promisor_remotes(&client_git, format, &[blob_oid])
3443                .expect("hydrate exact object");
3444        assert_eq!(hydrated, vec![blob_oid]);
3445        assert!(
3446            FileObjectDatabase::from_git_dir(&client_git, format)
3447                .contains(&blob_oid)
3448                .expect("client object lookup")
3449        );
3450
3451        fs::remove_dir_all(root).expect("remove test repositories");
3452    }
3453
3454    #[test]
3455    fn accepted_promisor_omits_gap_while_rejection_hydrates_server() {
3456        let root = unique_local_test_dir("promisor-traversal-policy");
3457        let origin_git = root.join("origin.git");
3458        let server_git = root.join("server.git");
3459        let lop_git = root.join("lop.git");
3460        let accepted_git = root.join("accepted.git");
3461        let rejected_git = root.join("rejected.git");
3462        for git_dir in [
3463            &origin_git,
3464            &server_git,
3465            &lop_git,
3466            &accepted_git,
3467            &rejected_git,
3468        ] {
3469            fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
3470            fs::create_dir_all(git_dir.join("refs")).expect("test repository refs");
3471            fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
3472                .expect("test repository HEAD");
3473        }
3474
3475        let format = ObjectFormat::Sha1;
3476        let origin_db = FileObjectDatabase::from_git_dir(&origin_git, format);
3477        let blob = EncodedObject::new(ObjectType::Blob, b"promised payload\n".to_vec());
3478        let blob_oid = write_test_object(&origin_db, &blob);
3479        let tree_oid =
3480            write_test_object(&origin_db, &test_tree(&[(0o100644, b"payload", blob_oid)]));
3481        let commit_oid = write_test_object(&origin_db, &test_commit(tree_oid, &[], b"tip\n"));
3482        write_test_object(&FileObjectDatabase::from_git_dir(&lop_git, format), &blob);
3483
3484        sley_odb::build_and_install_reachable_pack_filtered(
3485            &origin_db,
3486            &FileObjectDatabase::from_git_dir(&server_git, format),
3487            format,
3488            vec![commit_oid],
3489            &HashSet::new(),
3490            RawPackInstallOptions {
3491                promisor: true,
3492                ..Default::default()
3493            },
3494            Some(sley_odb::PackObjectFilter::BlobNone),
3495            None,
3496        )
3497        .expect("create incomplete promisor server")
3498        .expect("promisor pack");
3499        fs::write(
3500            server_git.join("config"),
3501            format!(
3502                "[remote \"lop\"]\n\tpromisor = true\n\turl = {}\n",
3503                lop_git.display()
3504            ),
3505        )
3506        .expect("server promisor config");
3507
3508        let advertisement = sley_protocol::PromisorRemoteAdvertisement {
3509            name: "lop".into(),
3510            url: lop_git.to_string_lossy().into_owned(),
3511            partial_clone_filter: None,
3512            token: None,
3513        };
3514        install_fetch_pack_via_local_upload_pack_with_promisor_decision(
3515            &accepted_git,
3516            &server_git,
3517            format,
3518            vec![commit_oid],
3519            None,
3520            true,
3521            false,
3522            Some(sley_odb::PackObjectFilter::BlobNone),
3523            Some(Vec::new()),
3524            false,
3525            None,
3526            &crate::PromisorRemoteDecision {
3527                accepted: vec![advertisement],
3528                reply: Some("lop".into()),
3529                stored_fields: Vec::new(),
3530            },
3531        )
3532        .expect("accepted promisor transfer");
3533        let server_db = FileObjectDatabase::from_git_dir(&server_git, format);
3534        assert!(!server_db.contains(&blob_oid).expect("server object lookup"));
3535
3536        install_fetch_pack_via_local_upload_pack(
3537            &rejected_git,
3538            &server_git,
3539            format,
3540            vec![commit_oid],
3541            None,
3542            true,
3543            false,
3544            Some(sley_odb::PackObjectFilter::BlobNone),
3545            Some(Vec::new()),
3546            false,
3547            None,
3548        )
3549        .expect("rejected promisor transfer hydrates server");
3550        server_db.refresh_read_cache();
3551        assert!(
3552            server_db
3553                .contains(&blob_oid)
3554                .expect("hydrated server lookup")
3555        );
3556        fs::remove_dir_all(root).expect("remove test repositories");
3557    }
3558
3559    #[test]
3560    fn configured_local_promisor_hydrates_reachable_gap_without_sidecar() {
3561        let root = unique_local_test_dir("promisor-repack-hydration");
3562        let origin_git = root.join("origin.git");
3563        let server_git = root.join("server.git");
3564        let lop_git = root.join("lop.git");
3565        for git_dir in [&origin_git, &server_git, &lop_git] {
3566            fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
3567            fs::create_dir_all(git_dir.join("refs")).expect("test repository refs");
3568            fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
3569                .expect("test repository HEAD");
3570        }
3571
3572        let format = ObjectFormat::Sha1;
3573        let origin_db = FileObjectDatabase::from_git_dir(&origin_git, format);
3574        let blob = EncodedObject::new(ObjectType::Blob, b"repack promised payload\n".to_vec());
3575        let blob_oid = write_test_object(&origin_db, &blob);
3576        let tree_oid =
3577            write_test_object(&origin_db, &test_tree(&[(0o100644, b"payload", blob_oid)]));
3578        let commit_oid = write_test_object(&origin_db, &test_commit(tree_oid, &[], b"tip\n"));
3579        write_test_object(&FileObjectDatabase::from_git_dir(&lop_git, format), &blob);
3580
3581        sley_odb::build_and_install_reachable_pack_filtered(
3582            &origin_db,
3583            &FileObjectDatabase::from_git_dir(&server_git, format),
3584            format,
3585            vec![commit_oid],
3586            &HashSet::new(),
3587            RawPackInstallOptions {
3588                promisor: true,
3589                ..Default::default()
3590            },
3591            Some(sley_odb::PackObjectFilter::BlobNone),
3592            None,
3593        )
3594        .expect("create incomplete promisor server")
3595        .expect("promisor pack");
3596        for entry in fs::read_dir(server_git.join("objects/pack")).expect("pack directory") {
3597            let path = entry.expect("pack entry").path();
3598            if path.extension().and_then(|ext| ext.to_str()) == Some("promisor") {
3599                fs::remove_file(path).expect("remove promisor classification");
3600            }
3601        }
3602        fs::write(
3603            server_git.join("config"),
3604            format!(
3605                "[remote \"lop\"]\n\tpromisor = true\n\turl = {}\n",
3606                lop_git.display()
3607            ),
3608        )
3609        .expect("server promisor config");
3610
3611        let server_db = FileObjectDatabase::from_git_dir(&server_git, format);
3612        assert!(
3613            !server_db
3614                .contains(&blob_oid)
3615                .expect("missing before hydrate")
3616        );
3617        hydrate_reachable_from_local_promisor_remotes(&server_git, format, &[commit_oid])
3618            .expect("hydrate configured promisor gap");
3619        server_db.refresh_read_cache();
3620        assert!(server_db.contains(&blob_oid).expect("hydrated blob lookup"));
3621
3622        fs::remove_dir_all(root).expect("remove test repositories");
3623    }
3624
3625    #[test]
3626    fn promisor_hydration_skips_objects_excluded_by_client_haves() {
3627        let root = unique_local_test_dir("promisor-fetch-exclusions");
3628        let origin_git = root.join("origin.git");
3629        let server_git = root.join("server.git");
3630        let lop_git = root.join("lop.git");
3631        for git_dir in [&origin_git, &server_git, &lop_git] {
3632            fs::create_dir_all(git_dir.join("objects")).expect("test repository objects");
3633            fs::create_dir_all(git_dir.join("refs")).expect("test repository refs");
3634            fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
3635                .expect("test repository HEAD");
3636        }
3637
3638        let format = ObjectFormat::Sha1;
3639        let origin_db = FileObjectDatabase::from_git_dir(&origin_git, format);
3640        let old_blob = EncodedObject::new(ObjectType::Blob, b"old promised payload\n".to_vec());
3641        let new_blob = EncodedObject::new(ObjectType::Blob, b"new promised payload\n".to_vec());
3642        let old_blob_oid = write_test_object(&origin_db, &old_blob);
3643        let new_blob_oid = write_test_object(&origin_db, &new_blob);
3644        let tree_oid = write_test_object(
3645            &origin_db,
3646            &test_tree(&[
3647                (0o100644, b"old", old_blob_oid),
3648                (0o100644, b"new", new_blob_oid),
3649            ]),
3650        );
3651        let commit_oid = write_test_object(&origin_db, &test_commit(tree_oid, &[], b"tip\n"));
3652        let lop_db = FileObjectDatabase::from_git_dir(&lop_git, format);
3653        write_test_object(&lop_db, &old_blob);
3654        write_test_object(&lop_db, &new_blob);
3655
3656        sley_odb::build_and_install_reachable_pack_filtered(
3657            &origin_db,
3658            &FileObjectDatabase::from_git_dir(&server_git, format),
3659            format,
3660            vec![commit_oid],
3661            &HashSet::new(),
3662            RawPackInstallOptions {
3663                promisor: true,
3664                ..Default::default()
3665            },
3666            Some(sley_odb::PackObjectFilter::BlobNone),
3667            None,
3668        )
3669        .expect("create incomplete promisor server")
3670        .expect("promisor pack");
3671        fs::write(
3672            server_git.join("config"),
3673            format!(
3674                "[remote \"lop\"]\n\tpromisor = true\n\turl = {}\n",
3675                lop_git.display()
3676            ),
3677        )
3678        .expect("server promisor config");
3679
3680        let server_db = FileObjectDatabase::from_git_dir(&server_git, format)
3681            .with_promisor_remote_present(true);
3682        hydrate_reachable_promised_objects(
3683            &server_git,
3684            &server_db,
3685            format,
3686            &[commit_oid],
3687            &HashSet::from([old_blob_oid]),
3688        )
3689        .expect("hydrate only non-excluded gap");
3690        server_db.refresh_read_cache();
3691        assert!(!server_db.contains(&old_blob_oid).expect("old blob lookup"));
3692        assert!(server_db.contains(&new_blob_oid).expect("new blob lookup"));
3693
3694        fs::remove_dir_all(root).expect("remove test repositories");
3695    }
3696
3697    fn unique_local_test_dir(name: &str) -> std::path::PathBuf {
3698        let nanos = SystemTime::now()
3699            .duration_since(UNIX_EPOCH)
3700            .expect("test operation should succeed")
3701            .as_nanos();
3702        let root =
3703            std::env::temp_dir().join(format!("sley-remote-{name}-{}-{nanos}", std::process::id()));
3704        fs::create_dir_all(&root).expect("test operation should succeed");
3705        root
3706    }
3707
3708    fn write_test_object(db: &FileObjectDatabase, object: &EncodedObject) -> ObjectId {
3709        db.write_object(object.clone())
3710            .expect("test operation should succeed")
3711    }
3712
3713    fn test_tree(entries: &[(u32, &[u8], ObjectId)]) -> EncodedObject {
3714        EncodedObject::new(
3715            ObjectType::Tree,
3716            Tree {
3717                entries: entries
3718                    .iter()
3719                    .map(|(mode, name, oid)| TreeEntry {
3720                        mode: *mode,
3721                        name: BString::from(*name),
3722                        oid: *oid,
3723                    })
3724                    .collect(),
3725            }
3726            .write(),
3727        )
3728    }
3729
3730    fn test_commit(tree: ObjectId, parents: &[ObjectId], message: &[u8]) -> EncodedObject {
3731        let identity = b"Example <example@example.invalid> 0 +0000".to_vec();
3732        EncodedObject::new(
3733            ObjectType::Commit,
3734            Commit {
3735                tree,
3736                parents: parents.to_vec(),
3737                author: identity.clone(),
3738                committer: identity,
3739                encoding: None,
3740                message: message.to_vec(),
3741            }
3742            .write(),
3743        )
3744    }
3745}