Skip to main content

sley_remote/
local.rs

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