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