Skip to main content

sley_remote/
local.rs

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