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, ProgressInstaller,
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::{ProgressSink, 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    progress: &mut dyn ProgressSink,
209) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
210    if request.wants.is_empty() {
211        return Ok(Vec::new());
212    }
213    let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
214    if request.deepen.is_none() && all_wants_present(&local_db, &request.wants)? {
215        return Ok(Vec::new());
216    }
217    let haves = request
218        .haves
219        .clone()
220        .map(Ok)
221        .unwrap_or_else(|| crate::local::local_have_oids(request.git_dir, request.format))?;
222    if request.protocol_v2 {
223        return git_protocol_v2_fetch_into_repository(&request, haves, &local_db, progress);
224    }
225
226    let upload_request = UploadPackRequest {
227        wants: request.wants,
228        capabilities: shallow_request_capabilities(request.deepen),
229        shallow: request.shallow,
230        deepen: request.deepen,
231        ..UploadPackRequest::default()
232    };
233    let mut stream =
234        connect_git_service(request.remote, GitService::UploadPack, None, request.config)?;
235    read_ref_advertisement_set(request.format, &mut stream)?;
236    write_upload_pack_request(&mut stream, Some(&upload_request))?;
237    write_upload_pack_negotiation_request(
238        &mut stream,
239        &UploadPackNegotiationRequest { haves, done: true },
240    )?;
241    stream.flush()?;
242    let _ = stream.shutdown(Shutdown::Write);
243    if request.deepen.is_some() {
244        let shallow_info = if request.promisor {
245            let (shallow_info, _) = install_upload_pack_shallow_raw_promisor_response_from_reader(
246                request.format,
247                &mut stream,
248                &local_db,
249                request.max_input_size,
250            )?;
251            shallow_info
252        } else {
253            let (shallow_info, _) = install_upload_pack_shallow_raw_response_from_reader(
254                request.format,
255                &mut stream,
256                &ProgressInstaller::new(&local_db, progress),
257                request.max_input_size,
258            )?;
259            shallow_info
260        };
261        return Ok(shallow_info);
262    }
263    if request.promisor {
264        install_upload_pack_raw_promisor_response_from_reader(
265            request.format,
266            &mut stream,
267            &local_db,
268            request.max_input_size,
269        )?;
270    } else {
271        install_upload_pack_raw_response_from_reader(
272            request.format,
273            &mut stream,
274            &ProgressInstaller::new(&local_db, progress),
275            request.max_input_size,
276        )?;
277    }
278    Ok(Vec::new())
279}
280
281pub(crate) fn plan_push_git(request: GitPushRequest<'_>) -> Result<GitPushPlan> {
282    let GitPushRequest {
283        git_dir,
284        common_git_dir,
285        format,
286        remote,
287        config,
288        refspecs,
289        force,
290    } = request;
291    let (stream, advertisements, features) = receive_pack_advertisements(remote, format, config)?;
292
293    let local_store = FileRefStore::new(git_dir, format);
294    let local_refs = crate::push::local_push_source_refs(&local_store, format)?;
295    let parsed_refspecs = refspecs
296        .iter()
297        .map(|refspec| parse_refspec(&crate::push::normalize_push_refspec(refspec)))
298        .collect::<Result<Vec<_>>>()?;
299    let mut command_forces = Vec::new();
300    for refspec in &parsed_refspecs {
301        for command in plan_push_commands(
302            format,
303            &local_refs,
304            &advertisements,
305            std::slice::from_ref(refspec),
306        )? {
307            command_forces.push((command, force || refspec.force));
308        }
309    }
310    let commands = command_forces
311        .iter()
312        .map(|(command, _)| command.clone())
313        .collect::<Vec<_>>();
314    if commands.is_empty() {
315        let mut stream = stream;
316        let _ = stream.shutdown(Shutdown::Both);
317        return Ok(GitPushPlan {
318            commands,
319            pack_objects: Vec::new(),
320            stream: None,
321            features,
322            advertisements,
323        });
324    }
325
326    let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
327    crate::push::reject_non_fast_forward_pushes(
328        common_git_dir,
329        &local_db,
330        format,
331        &command_forces,
332    )?;
333    Ok(GitPushPlan {
334        commands,
335        pack_objects: Vec::new(),
336        stream: Some(stream),
337        features,
338        advertisements,
339    })
340}
341
342pub(crate) fn plan_push_git_commands(request: GitPushCommandsRequest<'_>) -> Result<GitPushPlan> {
343    let GitPushCommandsRequest {
344        common_git_dir,
345        format,
346        remote,
347        config,
348        command_forces,
349        pack_objects,
350    } = request;
351    let (stream, advertisements, features) = receive_pack_advertisements(remote, format, config)?;
352    let commands = command_forces
353        .iter()
354        .map(|(command, _)| command.clone())
355        .collect::<Vec<_>>();
356    if commands.is_empty() {
357        let mut stream = stream;
358        let _ = stream.shutdown(Shutdown::Both);
359        return Ok(GitPushPlan {
360            commands,
361            pack_objects,
362            stream: None,
363            features,
364            advertisements,
365        });
366    }
367
368    let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
369    crate::push::reject_non_fast_forward_pushes(
370        common_git_dir,
371        &local_db,
372        format,
373        &command_forces,
374    )?;
375    Ok(GitPushPlan {
376        commands,
377        pack_objects,
378        stream: Some(stream),
379        features,
380        advertisements,
381    })
382}
383
384pub(crate) fn execute_push_git_plan(
385    request: PushRequest<'_>,
386    mut plan: GitPushPlan,
387) -> Result<PushOutcome> {
388    if plan.commands.is_empty() {
389        return Ok(PushOutcome::default());
390    }
391    let mut stream = plan
392        .stream
393        .take()
394        .ok_or_else(|| GitError::Command("git:// receive-pack stream was not available".into()))?;
395    let commands = plan.commands.clone();
396    let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
397    crate::pack::write_receive_pack_body(
398        &crate::pack::PushPackRequest {
399            local_db: &local_db,
400            format: request.format,
401            commands: &commands,
402            pack_objects: &plan.pack_objects,
403            remote_advertisements: &plan.advertisements,
404            features: &plan.features,
405            options: crate::push::receive_pack_push_options(
406                &plan.features,
407                request.format,
408                request.options.quiet,
409                false,
410                &[],
411            ),
412            thin: request.options.thin.wants_thin(),
413        },
414        &mut stream,
415    )?;
416    stream.flush()?;
417    let _ = stream.shutdown(Shutdown::Write);
418
419    let report = if plan.features.report_status || plan.features.report_status_v2 {
420        let report = crate::push::read_receive_pack_push_report(
421            request.format,
422            &mut stream,
423            plan.features.report_status_v2,
424            plan.features.side_band_64k,
425        )?;
426        crate::push::validate_receive_pack_unpack(&report)?;
427        Some(report)
428    } else {
429        let mut sink = Vec::new();
430        stream.read_to_end(&mut sink)?;
431        None
432    };
433    Ok(PushOutcome {
434        commands,
435        report,
436        remote_progress: Vec::new(),
437    })
438}
439
440fn receive_pack_advertisements(
441    remote: &RemoteUrl,
442    format: ObjectFormat,
443    config: Option<&GitConfig>,
444) -> Result<(GitConnection, Vec<RefAdvertisement>, ReceivePackFeatures)> {
445    let mut stream = connect_git_service(remote, GitService::ReceivePack, None, config)?;
446    let advertisement_set = read_ref_advertisement_set(format, &mut stream)?;
447    let features = advertisement_set
448        .refs
449        .first()
450        .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
451        .transpose()?
452        .unwrap_or_default();
453    if let Some(remote_format) = features.object_format {
454        if remote_format != format {
455            return Err(GitError::InvalidObjectId(format!(
456                "remote repository uses {}, local repository uses {}",
457                remote_format.name(),
458                format.name()
459            )));
460        }
461    } else if format != ObjectFormat::Sha1 {
462        return Err(GitError::InvalidObjectId(format!(
463            "remote repository did not advertise object-format for {} push",
464            format.name()
465        )));
466    }
467    Ok((stream, advertisement_set.refs, features))
468}
469
470fn connect_git_service(
471    remote: &RemoteUrl,
472    service: GitService,
473    protocol: Option<ProtocolVersion>,
474    config: Option<&GitConfig>,
475) -> Result<GitConnection> {
476    if remote.transport != RemoteTransport::Git {
477        return Err(GitError::InvalidFormat(
478            "git:// service requires a git remote".into(),
479        ));
480    }
481    let host = remote
482        .host
483        .as_deref()
484        .ok_or_else(|| GitError::InvalidFormat("git:// remote is missing a host".into()))?;
485    let port = remote.port.unwrap_or(GIT_DAEMON_PORT);
486    let mut stream = crate::git_proxy::connect_git_transport(config, host, port)?;
487    let request = ServiceRequest {
488        service,
489        path: remote.path.clone(),
490        host: Some(git_host_parameter(remote, host, port)),
491        parameters: Vec::new(),
492        protocol,
493        extra_parameters: Vec::new(),
494    };
495    write_service_request(&mut stream, &request)?;
496    stream.flush()?;
497    Ok(stream)
498}
499
500fn git_protocol_v2_command_options(format: ObjectFormat) -> Vec<Capability> {
501    sley_protocol::encode_protocol_v2_command_options(&ProtocolV2CommandOptions {
502        object_format: Some(format),
503        ..ProtocolV2CommandOptions::default()
504    })
505    .unwrap_or_default()
506}
507
508fn git_protocol_v2_ls_refs(
509    remote: &RemoteUrl,
510    format: ObjectFormat,
511    config: Option<&GitConfig>,
512) -> Result<sley_protocol::RefAdvertisementSet> {
513    let mut stream = connect_git_service(
514        remote,
515        GitService::UploadPack,
516        Some(ProtocolVersion::V2),
517        config,
518    )?;
519    let handshake = read_protocol_v2_advertisement(&mut stream)?;
520    let object_format = protocol_v2_object_format(&handshake.capabilities)?;
521    if object_format != format {
522        return Err(GitError::InvalidObjectId(format!(
523            "remote repository uses {}, local repository uses {}",
524            object_format.name(),
525            format.name()
526        )));
527    }
528    git_protocol_v2_ls_refs_on_stream(format, &mut stream)
529}
530
531fn git_protocol_v2_ls_refs_on_stream(
532    format: ObjectFormat,
533    stream: &mut GitConnection,
534) -> Result<sley_protocol::RefAdvertisementSet> {
535    let mut request = ProtocolV2LsRefsRequest {
536        peel: true,
537        symrefs: true,
538        unborn: true,
539        ref_prefixes: vec!["HEAD".into(), "refs/heads/".into(), "refs/tags/".into()],
540    }
541    .to_command_request()?;
542    request.capabilities = git_protocol_v2_command_options(format);
543    write_protocol_v2_command_request(stream, &request)?;
544    stream.flush()?;
545    let records = read_protocol_v2_ls_refs_response(format, stream)?;
546    protocol_v2_ls_refs_records_to_ref_advertisement_set(&records)
547}
548
549fn upload_pack_features_from_v2(
550    handshake: &TransportHandshake,
551    refs: &[RefAdvertisement],
552) -> Result<UploadPackFeatures> {
553    let v2 = parse_protocol_v2_fetch_features(&handshake.capabilities)?.unwrap_or_default();
554    let mut features = UploadPackFeatures {
555        object_format: Some(protocol_v2_object_format(&handshake.capabilities)?),
556        shallow: v2.shallow,
557        deepen_since: v2.shallow,
558        deepen_not: v2.shallow,
559        filter: v2.filter,
560        ..UploadPackFeatures::default()
561    };
562    if let Some(first) = refs.first() {
563        let bridged = parse_upload_pack_features(&first.capabilities)?;
564        features.symrefs = bridged.symrefs;
565    }
566    Ok(features)
567}
568
569fn git_protocol_v2_fetch_into_repository(
570    request: &GitFetchPackRequest<'_>,
571    haves: Vec<ObjectId>,
572    local_db: &FileObjectDatabase,
573    progress: &mut dyn ProgressSink,
574) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
575    let mut stream = connect_git_service(
576        request.remote,
577        GitService::UploadPack,
578        Some(ProtocolVersion::V2),
579        request.config,
580    )?;
581    let handshake = read_protocol_v2_advertisement(&mut stream)?;
582    let v2_features =
583        parse_protocol_v2_fetch_features(&handshake.capabilities)?.unwrap_or_default();
584    let fetch = ProtocolV2FetchRequest {
585        wants: request.wants.clone(),
586        haves,
587        shallow: request.shallow.clone(),
588        deepen: request.deepen,
589        thin_pack: true,
590        include_tag: true,
591        ofs_delta: true,
592        done: true,
593        wait_for_done: v2_features.wait_for_done,
594        sideband_all: v2_features.sideband_all,
595        ..ProtocolV2FetchRequest::default()
596    };
597    write_protocol_v2_fetch_request(&mut stream, &fetch)?;
598    stream.flush()?;
599    let _ = stream.shutdown(Shutdown::Write);
600    let (header, _install) = if request.promisor {
601        install_protocol_v2_fetch_promisor_response_from_reader(
602            request.format,
603            &mut stream,
604            v2_features.sideband_all,
605            local_db,
606            request.max_input_size,
607        )?
608    } else {
609        install_protocol_v2_fetch_response_from_reader(
610            request.format,
611            &mut stream,
612            v2_features.sideband_all,
613            &ProgressInstaller::new(local_db, progress),
614            request.max_input_size,
615        )?
616    };
617    Ok(shallow_info_from_protocol_v2_fetch_header(&header))
618}
619
620fn git_host_parameter(remote: &RemoteUrl, host: &str, port: u16) -> String {
621    match remote.port {
622        Some(_) if host.contains(':') && !host.starts_with('[') => format!("[{host}]:{port}"),
623        Some(_) => format!("{host}:{port}"),
624        None => host.to_string(),
625    }
626}
627
628fn shallow_request_capabilities(deepen: Option<u32>) -> Vec<Capability> {
629    if deepen.is_some() {
630        vec![Capability {
631            name: "shallow".into(),
632            value: None,
633        }]
634    } else {
635        Vec::new()
636    }
637}
638
639fn all_wants_present(db: &FileObjectDatabase, wants: &[ObjectId]) -> Result<bool> {
640    for want in wants {
641        if !db.contains(want)? {
642            return Ok(false);
643        }
644    }
645    Ok(true)
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use std::net::TcpListener;
652    use std::thread;
653
654    use sley_protocol::{ProtocolVersion, RefAdvertisement, RefAdvertisementSet};
655    use sley_transport::read_service_request;
656
657    #[test]
658    fn ls_remote_git_sends_daemon_request_and_reads_advertisements() {
659        let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind daemon");
660        let port = listener.local_addr().expect("local addr").port();
661        let tip = ObjectId::from_hex(
662            ObjectFormat::Sha1,
663            "1111111111111111111111111111111111111111",
664        )
665        .expect("oid");
666        let server = thread::spawn(move || {
667            let (mut stream, _) = listener.accept().expect("accept daemon client");
668            let request = read_service_request(&mut stream).expect("service request");
669            assert_eq!(request.service, GitService::UploadPack);
670            assert_eq!(request.path, "/repo.git");
671            let expected_host = format!("127.0.0.1:{port}");
672            assert_eq!(request.host.as_deref(), Some(expected_host.as_str()));
673            sley_protocol::write_ref_advertisement_set(
674                &mut stream,
675                &RefAdvertisementSet {
676                    protocol: ProtocolVersion::V0,
677                    refs: vec![
678                        RefAdvertisement {
679                            oid: tip,
680                            name: "HEAD".into(),
681                            capabilities: vec![Capability {
682                                name: "symref".into(),
683                                value: Some("HEAD:refs/heads/main".into()),
684                            }],
685                        },
686                        RefAdvertisement {
687                            oid: tip,
688                            name: "refs/heads/main".into(),
689                            capabilities: Vec::new(),
690                        },
691                    ],
692                    shallow: Vec::new(),
693                },
694            )
695            .expect("write advertisements");
696        });
697        let remote = RemoteUrl {
698            transport: RemoteTransport::Git,
699            user: None,
700            password: None,
701            host: Some("127.0.0.1".into()),
702            port: Some(port),
703            path: "/repo.git".into(),
704        };
705        let (records, format) = ls_remote_git(
706            &remote,
707            &crate::ls_remote::LsRemoteFilter::default(),
708            &|_| true,
709            false,
710            None,
711        )
712        .expect("ls-remote");
713
714        server.join().expect("server thread");
715        assert_eq!(format, ObjectFormat::Sha1);
716        assert_eq!(records.len(), 2);
717        assert_eq!(records[0].name, "HEAD");
718        assert_eq!(records[0].symref.as_deref(), Some("refs/heads/main"));
719        assert_eq!(records[1].name, "refs/heads/main");
720        assert_eq!(records[1].oid, tip);
721    }
722}