Skip to main content

grit_lib/serve/
mod.rs

1//! Server side of the Git wire protocol: answering fetches and accepting pushes.
2//!
3//! This module lets a process act as the remote end of a clone, fetch, or push.
4//! [`upload_pack`] serves objects to a fetching client (protocol v0, v1, and v2)
5//! and [`receive_pack`] accepts a pushed pack plus ref updates. Both operate on
6//! plain byte streams, so the same code serves a duplex connection (ssh, a
7//! local pipe) and the stateless request/response exchanges of smart HTTP.
8//!
9//! A push server decides what to accept with a [`ReceivePolicy`] (the
10//! `receive.*` settings), the repository's hook scripts, and optionally an
11//! in-process [`ReceiveHook`] passed to [`receive_pack_with_hook`]. Refusals
12//! come back typed, as [`RefUpdateRefusal`] per ref and [`UnpackError`] for the
13//! pack.
14//!
15//! The functions take an already-opened [`Repository`] and explicit options; they
16//! never read the process environment or print to the terminal. Callers are
17//! expected to map `GIT_PROTOCOL` to a [`ProtocolVersion`] and to load
18//! configuration at their own boundary.
19
20mod connectivity;
21mod object_info;
22pub(crate) mod promised_objects;
23mod receive_pack;
24mod ref_update_policy;
25mod shallow_upload;
26mod upload_pack;
27mod upload_pack_policy;
28
29pub(crate) use receive_pack::ReceiveHookScripts;
30pub(crate) use shallow_upload::{compute_shallow_response, ShallowRequest};
31
32pub use receive_pack::{
33    receive_pack, receive_pack_with_hook, receive_pack_with_hook_cancellable, HookRunRecord,
34    ReceiveHook, ReceiveHookContext, ReceiveOutcome, ReceivePolicy, RefCommand, RefUpdateRefusal,
35    RefUpdateResult, RejectedObject, UnpackError,
36};
37pub use ref_update_policy::{checked_out_branch, head_branch, validate_ref_update, HeadBranches};
38pub use upload_pack::{upload_pack, upload_pack_cancellable, UploadOutcome};
39
40use std::ops::ControlFlow;
41
42use crate::objects::ObjectId;
43use crate::refs::listing::PACKED_PEELS_MIN_REFS;
44use crate::refs::store::RawRef;
45use crate::refs::PackedRefPeels;
46use crate::repo::Repository;
47
48/// Errors produced while serving a fetch or a push.
49#[derive(Debug, thiserror::Error)]
50pub enum ServeError {
51    /// The peer sent something that does not follow the wire protocol.
52    #[error("protocol error: {0}")]
53    Protocol(String),
54    /// The client asked for an object that is not an advertised ref tip.
55    #[error("not our ref: {0}")]
56    NotOurRef(ObjectId),
57    /// An object the response needs is not stored in the served repository.
58    ///
59    /// Only raised before any pack byte is written, so the client gets a clean
60    /// `ERR` line (protocol v2) or a side-band error (v0/v1) instead of a pack cut
61    /// short. Repositories without promisor remotes report missing objects the
62    /// way they always did, as a [`ServeError::Repository`] error.
63    #[error("object {oid} is missing: {reason}")]
64    ObjectMissing {
65        /// The missing object.
66        oid: ObjectId,
67        /// Why it could not be sent.
68        reason: MissingObjectReason,
69    },
70    /// The client requested a v2 command this server does not implement.
71    #[error("unknown command: {0}")]
72    UnknownCommand(String),
73    /// A `deepen-not` argument (`--shallow-exclude`) cannot be used as a
74    /// shallow-history exclusion point.
75    #[error("invalid deepen-not '{spec}': {reason}")]
76    InvalidDeepenNot {
77        /// The argument exactly as the client sent it.
78        spec: String,
79        /// Why it was rejected.
80        reason: DeepenNotError,
81    },
82    /// An upload-pack filter request was rejected by repository config.
83    #[error(transparent)]
84    UploadFilter(#[from] crate::upload_filter::UploadFilterError),
85    /// The client and repository disagree on the object hash algorithm.
86    #[error("object format mismatch: client wants {client}, repository uses {repository}")]
87    ObjectFormatMismatch {
88        /// Hash algorithm named by the client.
89        client: String,
90        /// Hash algorithm of the served repository.
91        repository: &'static str,
92    },
93    /// Reading or writing repository data failed.
94    #[error(transparent)]
95    Repository(#[from] crate::error::Error),
96    /// The underlying stream failed.
97    #[error(transparent)]
98    Io(#[from] std::io::Error),
99    /// The session was stopped by a cancel signal before finishing.
100    #[error("cancelled")]
101    Cancelled,
102    /// Sending the pack failed after part of it had gone out, so the response
103    /// cannot be turned into a clean error any more. With a side band the
104    /// client was told on band 3; the transport should end the response as
105    /// failed rather than complete it.
106    #[error("the pack failed partway through: {0}")]
107    PackInterrupted(Box<ServeError>),
108}
109
110/// Why a missing object could not be served; see [`ServeError::ObjectMissing`].
111///
112/// A repository whose large blobs live on a large-object promisor (LOP) leaves
113/// them out of its own packs. Such a blob is *promised*: some object in a promisor
114/// pack refers to it. A client that accepted one of the advertised promisor
115/// remotes is sent the pack without it and fetches it from the LOP itself; any
116/// other client needs the server to fetch it first (lazy fetch, see
117/// [`ServeOptions::lazy_fetch`]) or gets this error.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
119#[non_exhaustive]
120pub enum MissingObjectReason {
121    /// The object can be fetched from a promisor remote, but the client accepted
122    /// none of them and lazy fetch is off.
123    #[error("it is stored on a promisor remote the client did not accept")]
124    Promised,
125    /// No promisor object refers to it: the repository is missing an object it
126    /// should have.
127    #[error("no promisor remote has it either")]
128    NotPromised,
129    /// Lazy fetch was requested, but the [`Repository`] was opened without a
130    /// [`PromisorFetcher`](crate::promisor_fetch::PromisorFetcher).
131    #[error("lazy fetch is on but no promisor fetcher is installed")]
132    NoFetcher,
133    /// Lazy fetch ran and the object is still not stored.
134    #[error("fetching it from the promisor remotes did not bring it in")]
135    LazyFetchFailed,
136}
137
138/// Why a `deepen-not` argument was rejected; see [`ServeError::InvalidDeepenNot`].
139#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
140pub enum DeepenNotError {
141    /// The argument names neither an existing ref nor an object in the repository.
142    #[error("no such ref or object")]
143    UnknownRef,
144    /// A short name matched more than one ref under the ref lookup rules.
145    #[error("matches more than one ref")]
146    AmbiguousRef,
147    /// The ref or object id does not lead to a commit once tags are peeled.
148    #[error("does not point at a commit")]
149    NotACommit,
150}
151
152/// Result alias for serving operations.
153pub type Result<T> = std::result::Result<T, ServeError>;
154
155/// Wire protocol version requested by the client.
156///
157/// Version 1 is version 0 with an explicit `version 1` line in front of the
158/// advertisement. Version 2 replaces the ref advertisement with a capability
159/// list and command requests.
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
161pub enum ProtocolVersion {
162    /// The original protocol, used when the client asks for nothing else.
163    #[default]
164    V0,
165    /// Version 0 framing announced with a `version 1` line.
166    V1,
167    /// Command-based protocol version 2.
168    V2,
169}
170
171impl ProtocolVersion {
172    /// Pick the highest version named in a `GIT_PROTOCOL` style value.
173    ///
174    /// The value is a colon-separated list of `key=value` entries; only
175    /// `version=N` entries are considered. Unknown or missing versions fall
176    /// back to [`ProtocolVersion::V0`].
177    ///
178    /// # Parameters
179    ///
180    /// - `value`: the raw value, for example `version=2` or `version=2:foo=bar`.
181    ///
182    /// # Returns
183    ///
184    /// The highest supported version the value asks for.
185    #[must_use]
186    pub fn from_git_protocol(value: &str) -> Self {
187        value
188            .split(':')
189            .filter_map(|entry| entry.strip_prefix("version="))
190            .filter_map(|v| match v {
191                "2" => Some(Self::V2),
192                "1" => Some(Self::V1),
193                "0" => Some(Self::V0),
194                _ => None,
195            })
196            .max_by_key(|v| match v {
197                Self::V0 => 0,
198                Self::V1 => 1,
199                Self::V2 => 2,
200            })
201            .unwrap_or_default()
202    }
203}
204
205/// How a serving session is framed on the wire.
206#[derive(Debug, Clone, Default)]
207pub struct ServeOptions {
208    /// Protocol version the client asked for.
209    pub protocol: ProtocolVersion,
210    /// Handle exactly one request from the input and stop, without printing the
211    /// advertisement first. Smart HTTP uses this for its POST requests.
212    pub stateless_rpc: bool,
213    /// Print the ref (or capability) advertisement and stop. Smart HTTP uses
214    /// this to answer `GET info/refs`.
215    pub advertise_refs: bool,
216    /// Value for the `agent=` capability, such as `grit/0.5.0`.
217    pub agent: String,
218    /// Ref patterns hidden from the advertisement (`transfer.hideRefs` and the
219    /// service-specific `uploadpack.hideRefs` / `receive.hideRefs`).
220    pub hidden_refs: Vec<String>,
221    /// Values the client sent as v2 `server-option=<value>` capability lines on
222    /// the current command request. Cleared at the start of each v2 command and
223    /// filled before `ls-refs` / `fetch` / `object-info` handlers run.
224    pub server_options: Vec<String>,
225    /// Bundles to offer through the protocol v2 `bundle-uri` command. `None`
226    /// leaves the command out of the advertisement.
227    pub bundle_list: Option<crate::bundle_uri::BundleList>,
228    /// Where clients may upload a resumable push bundle, advertised by
229    /// receive-pack as `pushBundle=<value>`; relative values are relative to
230    /// the repository URL. `None` advertises nothing.
231    pub push_bundle: Option<String>,
232    /// Filled while serving: the promisor remotes the client accepted, from the
233    /// `promisor-remote=<names>` capability of its v2 command requests. Only names
234    /// this server advertised (see
235    /// [`PromisorAdvertisement`](crate::promisor_remote::PromisorAdvertisement))
236    /// are kept. The list lasts for the whole session, so an acceptance sent with
237    /// `ls-refs` still holds for the `fetch` that follows; a stateless (smart
238    /// HTTP) request only sees what that one request carried. Callers leave it
239    /// empty.
240    pub accepted_promisor_remotes: Vec<String>,
241    /// Fetch objects this repository is missing from its own promisor remotes
242    /// before answering a client that accepted none of them, so the client still
243    /// gets a complete pack. `None` uses `uploadpack.lazyFetch` (a grit setting,
244    /// false by default).
245    ///
246    /// Git's upload-pack turns lazy fetch off unless `GIT_NO_LAZY_FETCH=0` is in
247    /// its environment; the library reads no environment, so callers map that
248    /// variable here. Fetching needs a
249    /// [`PromisorFetcher`](crate::promisor_fetch::PromisorFetcher) on the
250    /// [`Repository`], otherwise the request fails with
251    /// [`MissingObjectReason::NoFetcher`].
252    pub lazy_fetch: Option<bool>,
253}
254
255impl ServeOptions {
256    /// The [`Self::lazy_fetch`] an upload-pack process should use under `env`, from
257    /// `GIT_NO_LAZY_FETCH` (or grit's `GRIT_NO_LAZY_FETCH`, which wins when both are
258    /// set): a false value turns lazy fetch on, a true value turns it off, and
259    /// unset (or not a boolean) leaves `uploadpack.lazyFetch` in charge.
260    ///
261    /// Git's upload-pack sets `GIT_NO_LAZY_FETCH=1` unless the variable is already
262    /// in its environment, so `GIT_NO_LAZY_FETCH=0` is how a Git server is told to
263    /// fetch promised objects for clients that did not accept its promisor remotes.
264    ///
265    /// # Parameters
266    ///
267    /// - `env`: the server process environment.
268    #[must_use]
269    pub fn lazy_fetch_from_environment(env: &crate::environment::Environment) -> Option<bool> {
270        [
271            crate::promisor_fetch::NO_LAZY_FETCH_ENV,
272            "GIT_NO_LAZY_FETCH",
273        ]
274        .into_iter()
275        .find_map(|key| env.var(key))
276        .and_then(|value| crate::config::parse_bool(value.trim()).ok())
277        .map(|no_lazy_fetch| !no_lazy_fetch)
278    }
279
280    /// Whether upload-pack may fetch missing promised objects for this session:
281    /// [`Self::lazy_fetch`] when set, otherwise `uploadpack.lazyFetch` from `config`.
282    ///
283    /// A caller uses this to decide whether to open the served [`Repository`] with a
284    /// [`PromisorFetcher`](crate::promisor_fetch::PromisorFetcher). Without one, a
285    /// served repository never fetches anything.
286    ///
287    /// # Parameters
288    ///
289    /// - `config`: the served repository's configuration.
290    #[must_use]
291    pub fn lazy_fetch_enabled(&self, config: &crate::config::ConfigSet) -> bool {
292        self.lazy_fetch.unwrap_or_else(|| {
293            config
294                .get_bool("uploadpack.lazyfetch")
295                .and_then(|v| v.ok())
296                .unwrap_or(false)
297        })
298    }
299
300    /// Give the repository an upload-pack session serves the promisor fetcher it may
301    /// use: `fetcher` when [`Self::lazy_fetch_enabled`], and none otherwise, so a
302    /// server without lazy fetch never fetches anything, whichever fetcher the
303    /// repository was opened with.
304    ///
305    /// Every upload-pack entry point (the `grit upload-pack` command, smart HTTP,
306    /// the in-process local transport) calls this right after opening the
307    /// repository.
308    ///
309    /// # Parameters
310    ///
311    /// - `repo`: the served repository.
312    /// - `config`: its configuration.
313    /// - `fetcher`: the fetcher to use when lazy fetch is on.
314    ///
315    /// # Returns
316    ///
317    /// Whether a fetcher is now installed.
318    pub fn attach_promisor_fetcher(
319        &self,
320        repo: &Repository,
321        config: &crate::config::ConfigSet,
322        fetcher: Option<std::sync::Arc<dyn crate::promisor_fetch::PromisorFetcher>>,
323    ) -> bool {
324        let fetcher = fetcher.filter(|_| self.lazy_fetch_enabled(config));
325        let attached = fetcher.is_some();
326        repo.caches().set_promisor_fetcher(fetcher);
327        attached
328    }
329}
330
331/// Name of the receive-pack capability that advertises a resumable push
332/// endpoint ([`ServeOptions::push_bundle`]).
333pub const PUSH_BUNDLE_CAPABILITY: &str = "pushBundle";
334
335/// One ref as it appears in an advertisement.
336#[derive(Debug, Clone)]
337pub(crate) struct AdvertisedRef {
338    /// Full ref name, such as `refs/heads/main`.
339    pub(crate) name: String,
340    /// Object the ref points at.
341    pub(crate) oid: ObjectId,
342    /// For an annotated tag, the non-tag object it ultimately points at.
343    pub(crate) peeled: Option<ObjectId>,
344}
345
346/// Whether [`advertised_refs`] fills [`AdvertisedRef::peeled`].
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub(crate) enum Peel {
349    /// Leave `peeled` empty and read no objects.
350    No,
351    /// Peel annotated tags, trusting `packed-refs` peel lines where they apply.
352    Yes,
353}
354
355/// List the repository's refs in advertisement order, skipping hidden ones.
356///
357/// Only refs whose names start with one of `prefixes` are listed (every ref
358/// under `refs/` when `prefixes` is empty), and the ref store is asked for
359/// those prefixes alone, so the cost follows the refs returned rather than the
360/// refs stored. Prefixes that cannot name a ref under `refs/` (such as `HEAD`)
361/// select nothing here.
362///
363/// Refs whose names are not safe to send and symbolic refs that do not resolve
364/// are left out. As in Git, a ref naming a missing object is still advertised;
365/// with [`Peel::Yes`] it, like any ref whose tag chain cannot be read, simply
366/// gets no peeled value.
367pub(crate) fn advertised_refs(
368    repo: &Repository,
369    hidden: &[String],
370    prefixes: &[&str],
371    peel: Peel,
372) -> Result<Vec<AdvertisedRef>> {
373    let store = repo.refs();
374    let git_dir = repo.git_dir();
375    let linked_worktree = crate::worktree_ref::is_linked_worktree_git_dir(git_dir);
376    let mut listed: Vec<(String, ObjectId)> = Vec::new();
377    for prefix in scan_prefixes(prefixes) {
378        store.for_each_ref(prefix, &mut |entry| {
379            let name = entry.name.as_str();
380            if !name.starts_with(prefix)
381                || !crate::refs::is_valid_fetch_advertised_ref(name)
382                || crate::hide_refs::ref_is_hidden(name, name, hidden)
383                || (linked_worktree
384                    && !crate::worktree_ref::ref_visible_from_worktree(git_dir, name))
385            {
386                return ControlFlow::Continue(());
387            }
388            let oid = match &entry.value {
389                RawRef::Direct(oid) => Some(*oid),
390                RawRef::Symbolic(_) => entry.peeled.or_else(|| store.resolve(name).ok()),
391            };
392            if let Some(oid) = oid {
393                listed.push((entry.name.clone(), oid));
394            }
395            ControlFlow::Continue(())
396        })?;
397    }
398    if !listed.is_sorted_by(|a, b| a.0 < b.0) {
399        listed.sort_by(|a, b| a.0.cmp(&b.0));
400        listed.dedup_by(|later, first| later.0 == first.0);
401    }
402
403    if peel == Peel::No {
404        return Ok(listed
405            .into_iter()
406            .map(|(name, oid)| AdvertisedRef {
407                name,
408                oid,
409                peeled: None,
410            })
411            .collect());
412    }
413    let odb = repo.odb();
414    // A malformed `packed-refs` only loses the shortcut; peeling falls back to the objects.
415    let packed = (listed.len() >= PACKED_PEELS_MIN_REFS)
416        .then(|| PackedRefPeels::for_store(store, git_dir).ok())
417        .flatten();
418    Ok(odb.with_pack_read_context(|| {
419        listed
420            .into_iter()
421            .map(|(name, oid)| {
422                let peeled = packed
423                    .as_ref()
424                    .and_then(|p| p.peeled(&name, &oid))
425                    .unwrap_or_else(|| odb.peel_tag_target(oid).ok().flatten());
426                AdvertisedRef { name, oid, peeled }
427            })
428            .collect()
429    }))
430}
431
432/// Ref-store prefixes to list so that every ref under `refs/` starting with one
433/// of `requested` is visited exactly once.
434///
435/// No request means all of `refs/`. A request shorter than `refs/` that it
436/// begins (`""`, `"re"`) widens to `refs/`; one outside it selects nothing.
437/// Prefixes covered by a shorter one are dropped, which leaves the result
438/// sorted and disjoint: listing each in turn yields names in sorted order.
439fn scan_prefixes<'a>(requested: &[&'a str]) -> Vec<&'a str> {
440    const ALL: &str = "refs/";
441    if requested.is_empty() {
442        return vec![ALL];
443    }
444    let mut scan: Vec<&str> = requested
445        .iter()
446        .filter_map(|p| {
447            if p.starts_with(ALL) {
448                Some(*p)
449            } else {
450                ALL.starts_with(p).then_some(ALL)
451            }
452        })
453        .collect();
454    scan.sort_unstable();
455    scan.dedup_by(|later, kept| later.starts_with(*kept));
456    scan
457}
458
459/// Where `HEAD` points: its symbolic target (if any) and resolved object (if born).
460pub(crate) struct HeadInfo {
461    /// Ref `HEAD` names, such as `refs/heads/main`, when `HEAD` is symbolic.
462    pub(crate) target: Option<String>,
463    /// Object `HEAD` resolves to; `None` on an unborn branch.
464    pub(crate) oid: Option<ObjectId>,
465}
466
467/// Read `HEAD` for the advertisement.
468pub(crate) fn head_info(repo: &Repository) -> HeadInfo {
469    HeadInfo {
470        target: crate::refs::read_symbolic_ref(repo.git_dir(), "HEAD")
471            .ok()
472            .flatten(),
473        oid: crate::refs::resolve_ref(repo.git_dir(), "HEAD").ok(),
474    }
475}
476
477/// Write a pkt-line carrying `data` followed by a newline.
478pub(crate) fn write_line(out: &mut dyn std::io::Write, data: &str) -> Result<()> {
479    let mut buf = Vec::with_capacity(data.len() + 5);
480    crate::pkt_line::write_line_to_vec(&mut buf, data)?;
481    out.write_all(&buf)?;
482    Ok(())
483}
484
485/// Write a flush packet.
486pub(crate) fn write_flush(out: &mut dyn std::io::Write) -> Result<()> {
487    out.write_all(crate::pkt_line::FLUSH.as_bytes())?;
488    Ok(())
489}
490
491/// Read one packet, normalising a data line by dropping its trailing newline.
492pub(crate) fn read_packet(input: &mut dyn std::io::Read) -> Result<Option<Packet>> {
493    let pkt = crate::pkt_line::read_packet(&mut &mut *input)?;
494    Ok(pkt.map(|p| match p {
495        crate::pkt_line::Packet::Data(s) => Packet::Data(s.trim_end_matches('\n').to_owned()),
496        crate::pkt_line::Packet::Flush => Packet::Flush,
497        crate::pkt_line::Packet::Delim => Packet::Delim,
498        crate::pkt_line::Packet::ResponseEnd => Packet::ResponseEnd,
499    }))
500}
501
502/// A packet read from the client, with data lines already trimmed.
503#[derive(Debug, PartialEq, Eq)]
504pub(crate) enum Packet {
505    /// A data line without its trailing newline.
506    Data(String),
507    /// `0000`.
508    Flush,
509    /// `0001`.
510    Delim,
511    /// `0002`.
512    ResponseEnd,
513}
514
515/// Parse a full-width hex object id for the repository's hash algorithm.
516pub(crate) fn parse_oid(repo: &Repository, hex: &str) -> Result<ObjectId> {
517    let oid = ObjectId::from_hex(hex)
518        .map_err(|_| ServeError::Protocol(format!("invalid object id '{hex}'")))?;
519    if oid.algo() != repo.odb().hash_algo() {
520        return Err(ServeError::Protocol(format!(
521            "object id '{hex}' does not match the repository hash algorithm"
522        )));
523    }
524    Ok(oid)
525}
526
527/// Check a client's `object-format=` capability against the repository.
528pub(crate) fn check_object_format(repo: &Repository, client: Option<&str>) -> Result<()> {
529    let ours = repo.odb().hash_algo().name();
530    match client {
531        Some(name) if name != ours => Err(ServeError::ObjectFormatMismatch {
532            client: name.to_owned(),
533            repository: ours,
534        }),
535        _ => Ok(()),
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::{advertised_refs, scan_prefixes, Peel, ProtocolVersion};
542    use crate::config::environment::RepositoryOptions;
543    use crate::objects::{ObjectId, ObjectKind};
544    use crate::repo::{InitOptions, Repository};
545
546    #[test]
547    fn scan_prefixes_collapse_to_disjoint_sorted_ref_prefixes() {
548        assert_eq!(scan_prefixes(&[]), vec!["refs/"]);
549        assert_eq!(scan_prefixes(&["HEAD"]), Vec::<&str>::new());
550        assert_eq!(scan_prefixes(&["", "HEAD"]), vec!["refs/"]);
551        assert_eq!(scan_prefixes(&["re", "refs/tags/"]), vec!["refs/"]);
552        assert_eq!(
553            scan_prefixes(&[
554                "refs/tags/v1",
555                "refs/heads/",
556                "refs/tags/",
557                "refs/heads/main"
558            ]),
559            vec!["refs/heads/", "refs/tags/"]
560        );
561        assert_eq!(
562            scan_prefixes(&["refs/tags/v2", "refs/tags/v1", "refs/tags/v1"]),
563            vec!["refs/tags/v1", "refs/tags/v2"]
564        );
565    }
566
567    /// A bare repository with `main`, `feature`, an annotated tag `v1`, a hidden ref, and a
568    /// ref naming a missing object.
569    fn fixture() -> (tempfile::TempDir, Repository, ObjectId, ObjectId) {
570        let dir = tempfile::tempdir().expect("tempdir");
571        Repository::init(
572            dir.path(),
573            &InitOptions::new()
574                .with_bare(true)
575                .with_initial_branch("main"),
576        )
577        .expect("init");
578        let repo = Repository::open(dir.path(), &RepositoryOptions::empty()).expect("open");
579        let tree = repo.odb().write(ObjectKind::Tree, b"").expect("tree");
580        let commit = repo
581            .odb()
582            .write(
583                ObjectKind::Commit,
584                format!("tree {tree}\nauthor A <a@e> 1 +0000\ncommitter A <a@e> 1 +0000\n\nc\n")
585                    .as_bytes(),
586            )
587            .expect("commit");
588        let tag = repo
589            .odb()
590            .write(
591                ObjectKind::Tag,
592                format!("object {commit}\ntype commit\ntag v1\ntagger A <a@e> 1 +0000\n\nv1\n")
593                    .as_bytes(),
594            )
595            .expect("tag");
596        let missing = ObjectId::from_hex("0123456789abcdef0123456789abcdef01234567").expect("oid");
597        for (name, oid) in [
598            ("refs/heads/main", commit),
599            ("refs/heads/feature", commit),
600            ("refs/heads/broken", missing),
601            ("refs/hidden/x", commit),
602            ("refs/tags/v1", tag),
603        ] {
604            let path = dir.path().join(name);
605            std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
606            std::fs::write(path, format!("{oid}\n")).expect("ref");
607        }
608        (dir, repo, commit, tag)
609    }
610
611    fn listed(refs: &[super::AdvertisedRef]) -> Vec<(&str, Option<ObjectId>)> {
612        refs.iter().map(|r| (r.name.as_str(), r.peeled)).collect()
613    }
614
615    #[cfg_attr(
616        not(feature = "full-tests"),
617        ignore = "starts system git or is heavy; run with --features full-tests"
618    )]
619    #[test]
620    fn advertised_refs_lists_requested_prefixes_and_skips_hidden() {
621        let (_dir, repo, commit, _tag) = fixture();
622        let hidden = vec!["refs/hidden".to_owned()];
623
624        let all = advertised_refs(&repo, &hidden, &[], Peel::Yes).expect("all");
625        assert_eq!(
626            listed(&all),
627            vec![
628                ("refs/heads/broken", None),
629                ("refs/heads/feature", None),
630                ("refs/heads/main", None),
631                ("refs/tags/v1", Some(commit)),
632            ]
633        );
634
635        let some = advertised_refs(
636            &repo,
637            &hidden,
638            &["refs/tags/", "refs/heads/m", "HEAD", "refs/hidden/"],
639            Peel::No,
640        )
641        .expect("prefixed");
642        assert_eq!(
643            listed(&some),
644            vec![("refs/heads/main", None), ("refs/tags/v1", None)]
645        );
646
647        let none = advertised_refs(&repo, &hidden, &["HEAD"], Peel::Yes).expect("HEAD only");
648        assert!(none.is_empty());
649    }
650
651    #[test]
652    fn git_protocol_picks_highest_version() {
653        assert_eq!(ProtocolVersion::from_git_protocol(""), ProtocolVersion::V0);
654        assert_eq!(
655            ProtocolVersion::from_git_protocol("version=1"),
656            ProtocolVersion::V1
657        );
658        assert_eq!(
659            ProtocolVersion::from_git_protocol("version=2:object-format=sha1"),
660            ProtocolVersion::V2
661        );
662        assert_eq!(
663            ProtocolVersion::from_git_protocol("version=9"),
664            ProtocolVersion::V0
665        );
666    }
667}