Skip to main content

sley_remote/
git.rs

1//! Native `git://` transport over a raw TCP stream.
2//!
3//! This is the anonymous git-daemon protocol: send one service request pkt-line,
4//! then speak upload-pack / receive-pack over the selected wire protocol.
5
6use std::collections::HashMap;
7use std::io::{Read, Write};
8use std::net::Shutdown;
9use std::path::Path;
10
11use sley_config::GitConfig;
12
13use crate::install::{
14    install_protocol_v2_fetch_promisor_response_from_reader,
15    install_protocol_v2_fetch_response_from_reader,
16    install_upload_pack_raw_promisor_response_from_reader,
17    install_upload_pack_raw_response_from_reader,
18    install_upload_pack_shallow_raw_promisor_response_from_reader,
19    install_upload_pack_shallow_raw_response_from_reader,
20    shallow_info_from_protocol_v2_fetch_header,
21};
22use sley_core::{Capability, GitError, ObjectFormat, ObjectId, Result};
23use sley_odb::FileObjectDatabase;
24use sley_protocol::{
25    parse_protocol_v2_fetch_features, parse_receive_pack_features, parse_refspec,
26    parse_upload_pack_features, plan_push_commands,
27    protocol_v2_ls_refs_records_to_ref_advertisement_set, protocol_v2_object_format,
28    read_protocol_v2_advertisement, read_protocol_v2_ls_refs_response,
29    read_receive_pack_report_status, read_ref_advertisement_set, write_protocol_v2_command_request,
30    write_protocol_v2_fetch_request, write_upload_pack_negotiation_request,
31    write_upload_pack_request, GitService, ProtocolV2CommandOptions, ProtocolV2FetchRequest,
32    ProtocolV2FetchShallowInfo, ProtocolV2LsRefsRequest, ProtocolVersion, ReceivePackCommand,
33    ReceivePackFeatures, ReceivePackPushRequestOptions, RefAdvertisement, TransportHandshake,
34    UploadPackFeatures, UploadPackNegotiationRequest, UploadPackRequest,
35};
36use sley_refs::FileRefStore;
37use sley_transport::{write_service_request, RemoteTransport, RemoteUrl, ServiceRequest};
38
39use crate::git_proxy::GitConnection;
40use crate::{PushOutcome, PushRequest};
41
42const GIT_DAEMON_PORT: u16 = 9418;
43
44pub(crate) struct GitPushRequest<'a> {
45    pub git_dir: &'a Path,
46    pub common_git_dir: &'a Path,
47    pub format: ObjectFormat,
48    pub remote: &'a RemoteUrl,
49    pub config: Option<&'a GitConfig>,
50    pub refspecs: &'a [String],
51    pub force: bool,
52}
53
54pub(crate) struct GitPushCommandsRequest<'a> {
55    pub common_git_dir: &'a Path,
56    pub format: ObjectFormat,
57    pub remote: &'a RemoteUrl,
58    pub config: Option<&'a GitConfig>,
59    pub command_forces: Vec<(ReceivePackCommand, bool)>,
60    pub pack_objects: Vec<ObjectId>,
61}
62
63pub(crate) struct GitPushPlan {
64    pub(crate) commands: Vec<ReceivePackCommand>,
65    pub(crate) pack_objects: Vec<ObjectId>,
66    stream: Option<GitConnection>,
67    features: ReceivePackFeatures,
68    advertisements: Vec<RefAdvertisement>,
69}
70
71pub struct GitFetchPackRequest<'a> {
72    pub git_dir: &'a Path,
73    pub format: ObjectFormat,
74    pub remote: &'a RemoteUrl,
75    pub config: Option<&'a GitConfig>,
76    pub features: &'a UploadPackFeatures,
77    pub wants: Vec<ObjectId>,
78    pub haves: Option<Vec<ObjectId>>,
79    pub shallow: Vec<ObjectId>,
80    pub deepen: Option<u32>,
81    pub promisor: bool,
82    pub protocol_v2: bool,
83    /// Maximum raw pack bytes to accept from the remote (`fetch.maxInputSize` /
84    /// `transfer.maxSize`). `None` means unlimited.
85    pub max_input_size: Option<u64>,
86}
87
88pub struct GitUploadPackAdvertisements {
89    pub refs: Vec<RefAdvertisement>,
90    pub features: UploadPackFeatures,
91    pub protocol_v2: bool,
92}
93
94pub(crate) fn ls_remote_git(
95    remote: &RemoteUrl,
96    filter: &crate::ls_remote::LsRemoteFilter,
97    matches: &dyn Fn(&str) -> bool,
98    protocol_v2: bool,
99    config: Option<&GitConfig>,
100) -> Result<(Vec<crate::ls_remote::LsRemoteRecord>, ObjectFormat)> {
101    let set = if protocol_v2 {
102        git_protocol_v2_ls_refs(remote, ObjectFormat::Sha1, config)?
103    } else {
104        let mut stream = connect_git_service(remote, GitService::UploadPack, None, config)?;
105        read_ref_advertisement_set(ObjectFormat::Sha1, &mut stream)?
106    };
107    let features = set
108        .refs
109        .first()
110        .map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
111        .transpose()?
112        .unwrap_or_default();
113    let format = features.object_format.unwrap_or(ObjectFormat::Sha1);
114    if format != ObjectFormat::Sha1 {
115        return Err(GitError::Unsupported(format!(
116            "git:// ls-remote currently supports SHA-1 advertisements, got {}",
117            format.name()
118        )));
119    }
120    let symrefs = features
121        .symrefs
122        .iter()
123        .filter_map(|symref| symref.split_once(':'))
124        .map(|(name, target)| (name.to_string(), target.to_string()))
125        .collect::<HashMap<_, _>>();
126    let mut records = Vec::new();
127    for advertisement in set.refs {
128        if advertisement.oid.is_null() {
129            continue;
130        }
131        if filter.refs_only && (advertisement.name == "HEAD" || advertisement.name.ends_with("^{}"))
132        {
133            continue;
134        }
135        let is_head = advertisement.name.starts_with("refs/heads/");
136        let is_tag = advertisement.name.starts_with("refs/tags/");
137        if (filter.heads || filter.tags) && !((filter.heads && is_head) || (filter.tags && is_tag))
138        {
139            continue;
140        }
141        if !matches(&advertisement.name) {
142            continue;
143        }
144        records.push(crate::ls_remote::LsRemoteRecord {
145            oid: advertisement.oid,
146            symref: symrefs.get(&advertisement.name).cloned(),
147            name: advertisement.name,
148        });
149    }
150    Ok((records, format))
151}
152
153pub fn git_upload_pack_advertisements(
154    remote: &RemoteUrl,
155    format: ObjectFormat,
156) -> Result<GitUploadPackAdvertisements> {
157    git_upload_pack_advertisements_with_protocol(remote, format, false, None)
158}
159
160pub fn git_upload_pack_advertisements_with_protocol(
161    remote: &RemoteUrl,
162    format: ObjectFormat,
163    protocol_v2: bool,
164    config: Option<&GitConfig>,
165) -> Result<GitUploadPackAdvertisements> {
166    if protocol_v2 {
167        let mut stream = connect_git_service(
168            remote,
169            GitService::UploadPack,
170            Some(ProtocolVersion::V2),
171            config,
172        )?;
173        let handshake = read_protocol_v2_advertisement(&mut stream)?;
174        let object_format = protocol_v2_object_format(&handshake.capabilities)?;
175        if object_format != format {
176            return Err(GitError::InvalidObjectId(format!(
177                "remote repository uses {}, local repository uses {}",
178                object_format.name(),
179                format.name()
180            )));
181        }
182        let set = git_protocol_v2_ls_refs_on_stream(format, &mut stream)?;
183        let features = upload_pack_features_from_v2(&handshake, &set.refs)?;
184        return Ok(GitUploadPackAdvertisements {
185            refs: set.refs,
186            features,
187            protocol_v2: true,
188        });
189    }
190
191    let mut stream = connect_git_service(remote, GitService::UploadPack, None, config)?;
192    let set = read_ref_advertisement_set(format, &mut stream)?;
193    let features = set
194        .refs
195        .first()
196        .map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
197        .transpose()?
198        .unwrap_or_default();
199    Ok(GitUploadPackAdvertisements {
200        refs: set.refs,
201        features,
202        protocol_v2: false,
203    })
204}
205
206pub fn install_fetch_pack_via_git_upload_pack(
207    request: GitFetchPackRequest<'_>,
208) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
209    if request.wants.is_empty() {
210        return Ok(Vec::new());
211    }
212    let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
213    if request.deepen.is_none() && all_wants_present(&local_db, &request.wants)? {
214        return Ok(Vec::new());
215    }
216    let haves = request
217        .haves
218        .clone()
219        .map(Ok)
220        .unwrap_or_else(|| crate::local::local_have_oids(request.git_dir, request.format))?;
221    if request.protocol_v2 {
222        return git_protocol_v2_fetch_into_repository(&request, haves, &local_db);
223    }
224
225    let upload_request = UploadPackRequest {
226        wants: request.wants,
227        capabilities: shallow_request_capabilities(request.deepen),
228        shallow: request.shallow,
229        deepen: request.deepen,
230        ..UploadPackRequest::default()
231    };
232    let mut stream =
233        connect_git_service(request.remote, GitService::UploadPack, None, request.config)?;
234    read_ref_advertisement_set(request.format, &mut stream)?;
235    write_upload_pack_request(&mut stream, Some(&upload_request))?;
236    write_upload_pack_negotiation_request(
237        &mut stream,
238        &UploadPackNegotiationRequest { haves, done: true },
239    )?;
240    stream.flush()?;
241    let _ = stream.shutdown(Shutdown::Write);
242    if request.deepen.is_some() {
243        let shallow_info = if request.promisor {
244            let (shallow_info, _) = install_upload_pack_shallow_raw_promisor_response_from_reader(
245                request.format,
246                &mut stream,
247                &local_db,
248                request.max_input_size,
249            )?;
250            shallow_info
251        } else {
252            let (shallow_info, _) = install_upload_pack_shallow_raw_response_from_reader(
253                request.format,
254                &mut stream,
255                &local_db,
256                request.max_input_size,
257            )?;
258            shallow_info
259        };
260        return Ok(shallow_info);
261    }
262    if request.promisor {
263        install_upload_pack_raw_promisor_response_from_reader(
264            request.format,
265            &mut stream,
266            &local_db,
267            request.max_input_size,
268        )?;
269    } else {
270        install_upload_pack_raw_response_from_reader(
271            request.format,
272            &mut stream,
273            &local_db,
274            request.max_input_size,
275        )?;
276    }
277    Ok(Vec::new())
278}
279
280pub(crate) fn plan_push_git(request: GitPushRequest<'_>) -> Result<GitPushPlan> {
281    let GitPushRequest {
282        git_dir,
283        common_git_dir,
284        format,
285        remote,
286        config,
287        refspecs,
288        force,
289    } = request;
290    let (stream, advertisements, features) = receive_pack_advertisements(remote, format, config)?;
291
292    let local_store = FileRefStore::new(git_dir, format);
293    let local_refs = crate::push::local_push_source_refs(&local_store, format)?;
294    let parsed_refspecs = refspecs
295        .iter()
296        .map(|refspec| parse_refspec(&crate::push::normalize_push_refspec(refspec)))
297        .collect::<Result<Vec<_>>>()?;
298    let mut command_forces = Vec::new();
299    for refspec in &parsed_refspecs {
300        for command in plan_push_commands(
301            format,
302            &local_refs,
303            &advertisements,
304            std::slice::from_ref(refspec),
305        )? {
306            command_forces.push((command, force || refspec.force));
307        }
308    }
309    let commands = command_forces
310        .iter()
311        .map(|(command, _)| command.clone())
312        .collect::<Vec<_>>();
313    if commands.is_empty() {
314        let mut stream = stream;
315        let _ = stream.shutdown(Shutdown::Both);
316        return Ok(GitPushPlan {
317            commands,
318            pack_objects: Vec::new(),
319            stream: None,
320            features,
321            advertisements,
322        });
323    }
324
325    let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
326    crate::push::reject_non_fast_forward_pushes(
327        common_git_dir,
328        &local_db,
329        format,
330        &command_forces,
331    )?;
332    Ok(GitPushPlan {
333        commands,
334        pack_objects: Vec::new(),
335        stream: Some(stream),
336        features,
337        advertisements,
338    })
339}
340
341pub(crate) fn plan_push_git_commands(request: GitPushCommandsRequest<'_>) -> Result<GitPushPlan> {
342    let GitPushCommandsRequest {
343        common_git_dir,
344        format,
345        remote,
346        config,
347        command_forces,
348        pack_objects,
349    } = request;
350    let (stream, advertisements, features) = receive_pack_advertisements(remote, format, config)?;
351    let commands = command_forces
352        .iter()
353        .map(|(command, _)| command.clone())
354        .collect::<Vec<_>>();
355    if commands.is_empty() {
356        let mut stream = stream;
357        let _ = stream.shutdown(Shutdown::Both);
358        return Ok(GitPushPlan {
359            commands,
360            pack_objects,
361            stream: None,
362            features,
363            advertisements,
364        });
365    }
366
367    let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
368    crate::push::reject_non_fast_forward_pushes(
369        common_git_dir,
370        &local_db,
371        format,
372        &command_forces,
373    )?;
374    Ok(GitPushPlan {
375        commands,
376        pack_objects,
377        stream: Some(stream),
378        features,
379        advertisements,
380    })
381}
382
383pub(crate) fn execute_push_git_plan(
384    request: PushRequest<'_>,
385    mut plan: GitPushPlan,
386) -> Result<PushOutcome> {
387    if plan.commands.is_empty() {
388        return Ok(PushOutcome::default());
389    }
390    let mut stream = plan
391        .stream
392        .take()
393        .ok_or_else(|| GitError::Command("git:// receive-pack stream was not available".into()))?;
394    let commands = plan.commands.clone();
395    let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
396    crate::pack::write_receive_pack_body(
397        &crate::pack::PushPackRequest {
398            local_db: &local_db,
399            format: request.format,
400            commands: &commands,
401            pack_objects: &plan.pack_objects,
402            remote_advertisements: &plan.advertisements,
403            features: &plan.features,
404            options: crate::push::receive_pack_push_options(
405                &plan.features,
406                request.format,
407                request.options.quiet,
408                false,
409                &[],
410            ),
411            thin: request.options.thin.wants_thin(),
412        },
413        &mut stream,
414    )?;
415    stream.flush()?;
416    let _ = stream.shutdown(Shutdown::Write);
417
418    let report = if plan.features.report_status || plan.features.report_status_v2 {
419        let report = crate::push::read_receive_pack_push_report(
420            request.format,
421            &mut stream,
422            plan.features.report_status_v2,
423            plan.features.side_band_64k,
424        )?;
425        crate::push::validate_receive_pack_unpack(&report)?;
426        Some(report)
427    } else {
428        let mut sink = Vec::new();
429        stream.read_to_end(&mut sink)?;
430        None
431    };
432    Ok(PushOutcome {
433        commands,
434        report,
435        remote_progress: Vec::new(),
436    })
437}
438
439fn receive_pack_advertisements(
440    remote: &RemoteUrl,
441    format: ObjectFormat,
442    config: Option<&GitConfig>,
443) -> Result<(GitConnection, Vec<RefAdvertisement>, ReceivePackFeatures)> {
444    let mut stream = connect_git_service(remote, GitService::ReceivePack, None, config)?;
445    let advertisement_set = read_ref_advertisement_set(format, &mut stream)?;
446    let features = advertisement_set
447        .refs
448        .first()
449        .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
450        .transpose()?
451        .unwrap_or_default();
452    if let Some(remote_format) = features.object_format {
453        if remote_format != format {
454            return Err(GitError::InvalidObjectId(format!(
455                "remote repository uses {}, local repository uses {}",
456                remote_format.name(),
457                format.name()
458            )));
459        }
460    } else if format != ObjectFormat::Sha1 {
461        return Err(GitError::InvalidObjectId(format!(
462            "remote repository did not advertise object-format for {} push",
463            format.name()
464        )));
465    }
466    Ok((stream, advertisement_set.refs, features))
467}
468
469fn connect_git_service(
470    remote: &RemoteUrl,
471    service: GitService,
472    protocol: Option<ProtocolVersion>,
473    config: Option<&GitConfig>,
474) -> Result<GitConnection> {
475    if remote.transport != RemoteTransport::Git {
476        return Err(GitError::InvalidFormat(
477            "git:// service requires a git remote".into(),
478        ));
479    }
480    let host = remote
481        .host
482        .as_deref()
483        .ok_or_else(|| GitError::InvalidFormat("git:// remote is missing a host".into()))?;
484    let port = remote.port.unwrap_or(GIT_DAEMON_PORT);
485    let mut stream = crate::git_proxy::connect_git_transport(config, host, port)?;
486    let request = ServiceRequest {
487        service,
488        path: remote.path.clone(),
489        host: Some(git_host_parameter(remote, host, port)),
490        parameters: Vec::new(),
491        protocol,
492        extra_parameters: Vec::new(),
493    };
494    write_service_request(&mut stream, &request)?;
495    stream.flush()?;
496    Ok(stream)
497}
498
499fn git_protocol_v2_command_options(format: ObjectFormat) -> Vec<Capability> {
500    sley_protocol::encode_protocol_v2_command_options(&ProtocolV2CommandOptions {
501        object_format: Some(format),
502        ..ProtocolV2CommandOptions::default()
503    })
504    .unwrap_or_default()
505}
506
507fn git_protocol_v2_ls_refs(
508    remote: &RemoteUrl,
509    format: ObjectFormat,
510    config: Option<&GitConfig>,
511) -> Result<sley_protocol::RefAdvertisementSet> {
512    let mut stream = connect_git_service(
513        remote,
514        GitService::UploadPack,
515        Some(ProtocolVersion::V2),
516        config,
517    )?;
518    let handshake = read_protocol_v2_advertisement(&mut stream)?;
519    let object_format = protocol_v2_object_format(&handshake.capabilities)?;
520    if object_format != format {
521        return Err(GitError::InvalidObjectId(format!(
522            "remote repository uses {}, local repository uses {}",
523            object_format.name(),
524            format.name()
525        )));
526    }
527    git_protocol_v2_ls_refs_on_stream(format, &mut stream)
528}
529
530fn git_protocol_v2_ls_refs_on_stream(
531    format: ObjectFormat,
532    stream: &mut GitConnection,
533) -> Result<sley_protocol::RefAdvertisementSet> {
534    let mut request = ProtocolV2LsRefsRequest {
535        peel: true,
536        symrefs: true,
537        unborn: true,
538        ref_prefixes: vec!["HEAD".into(), "refs/heads/".into(), "refs/tags/".into()],
539    }
540    .to_command_request()?;
541    request.capabilities = git_protocol_v2_command_options(format);
542    write_protocol_v2_command_request(stream, &request)?;
543    stream.flush()?;
544    let records = read_protocol_v2_ls_refs_response(format, stream)?;
545    protocol_v2_ls_refs_records_to_ref_advertisement_set(&records)
546}
547
548fn upload_pack_features_from_v2(
549    handshake: &TransportHandshake,
550    refs: &[RefAdvertisement],
551) -> Result<UploadPackFeatures> {
552    let v2 = parse_protocol_v2_fetch_features(&handshake.capabilities)?.unwrap_or_default();
553    let mut features = UploadPackFeatures {
554        object_format: Some(protocol_v2_object_format(&handshake.capabilities)?),
555        shallow: v2.shallow,
556        deepen_since: v2.shallow,
557        deepen_not: v2.shallow,
558        filter: v2.filter,
559        ..UploadPackFeatures::default()
560    };
561    if let Some(first) = refs.first() {
562        let bridged = parse_upload_pack_features(&first.capabilities)?;
563        features.symrefs = bridged.symrefs;
564    }
565    Ok(features)
566}
567
568fn git_protocol_v2_fetch_into_repository(
569    request: &GitFetchPackRequest<'_>,
570    haves: Vec<ObjectId>,
571    local_db: &FileObjectDatabase,
572) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
573    let mut stream = connect_git_service(
574        request.remote,
575        GitService::UploadPack,
576        Some(ProtocolVersion::V2),
577        request.config,
578    )?;
579    let handshake = read_protocol_v2_advertisement(&mut stream)?;
580    let v2_features =
581        parse_protocol_v2_fetch_features(&handshake.capabilities)?.unwrap_or_default();
582    let fetch = ProtocolV2FetchRequest {
583        wants: request.wants.clone(),
584        haves,
585        shallow: request.shallow.clone(),
586        deepen: request.deepen,
587        thin_pack: true,
588        include_tag: true,
589        ofs_delta: true,
590        done: true,
591        wait_for_done: v2_features.wait_for_done,
592        sideband_all: v2_features.sideband_all,
593        ..ProtocolV2FetchRequest::default()
594    };
595    write_protocol_v2_fetch_request(&mut stream, &fetch)?;
596    stream.flush()?;
597    let _ = stream.shutdown(Shutdown::Write);
598    let (header, _install) = if request.promisor {
599        install_protocol_v2_fetch_promisor_response_from_reader(
600            request.format,
601            &mut stream,
602            v2_features.sideband_all,
603            local_db,
604            request.max_input_size,
605        )?
606    } else {
607        install_protocol_v2_fetch_response_from_reader(
608            request.format,
609            &mut stream,
610            v2_features.sideband_all,
611            local_db,
612            request.max_input_size,
613        )?
614    };
615    Ok(shallow_info_from_protocol_v2_fetch_header(&header))
616}
617
618fn git_host_parameter(remote: &RemoteUrl, host: &str, port: u16) -> String {
619    match remote.port {
620        Some(_) if host.contains(':') && !host.starts_with('[') => format!("[{host}]:{port}"),
621        Some(_) => format!("{host}:{port}"),
622        None => host.to_string(),
623    }
624}
625
626fn shallow_request_capabilities(deepen: Option<u32>) -> Vec<Capability> {
627    if deepen.is_some() {
628        vec![Capability {
629            name: "shallow".into(),
630            value: None,
631        }]
632    } else {
633        Vec::new()
634    }
635}
636
637fn all_wants_present(db: &FileObjectDatabase, wants: &[ObjectId]) -> Result<bool> {
638    for want in wants {
639        if !db.contains(want)? {
640            return Ok(false);
641        }
642    }
643    Ok(true)
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649    use std::net::TcpListener;
650    use std::thread;
651
652    use sley_protocol::{ProtocolVersion, RefAdvertisement, RefAdvertisementSet};
653    use sley_transport::read_service_request;
654
655    #[test]
656    fn ls_remote_git_sends_daemon_request_and_reads_advertisements() {
657        let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind daemon");
658        let port = listener.local_addr().expect("local addr").port();
659        let tip = ObjectId::from_hex(
660            ObjectFormat::Sha1,
661            "1111111111111111111111111111111111111111",
662        )
663        .expect("oid");
664        let server = thread::spawn(move || {
665            let (mut stream, _) = listener.accept().expect("accept daemon client");
666            let request = read_service_request(&mut stream).expect("service request");
667            assert_eq!(request.service, GitService::UploadPack);
668            assert_eq!(request.path, "/repo.git");
669            let expected_host = format!("127.0.0.1:{port}");
670            assert_eq!(request.host.as_deref(), Some(expected_host.as_str()));
671            sley_protocol::write_ref_advertisement_set(
672                &mut stream,
673                &RefAdvertisementSet {
674                    protocol: ProtocolVersion::V0,
675                    refs: vec![
676                        RefAdvertisement {
677                            oid: tip,
678                            name: "HEAD".into(),
679                            capabilities: vec![Capability {
680                                name: "symref".into(),
681                                value: Some("HEAD:refs/heads/main".into()),
682                            }],
683                        },
684                        RefAdvertisement {
685                            oid: tip,
686                            name: "refs/heads/main".into(),
687                            capabilities: Vec::new(),
688                        },
689                    ],
690                    shallow: Vec::new(),
691                },
692            )
693            .expect("write advertisements");
694        });
695        let remote = RemoteUrl {
696            transport: RemoteTransport::Git,
697            user: None,
698            password: None,
699            host: Some("127.0.0.1".into()),
700            port: Some(port),
701            path: "/repo.git".into(),
702        };
703        let (records, format) = ls_remote_git(
704            &remote,
705            &crate::ls_remote::LsRemoteFilter::default(),
706            &|_| true,
707            false,
708            None,
709        )
710        .expect("ls-remote");
711
712        server.join().expect("server thread");
713        assert_eq!(format, ObjectFormat::Sha1);
714        assert_eq!(records.len(), 2);
715        assert_eq!(records[0].name, "HEAD");
716        assert_eq!(records[0].symref.as_deref(), Some("refs/heads/main"));
717        assert_eq!(records[1].name, "refs/heads/main");
718        assert_eq!(records[1].oid, tip);
719    }
720}