1use 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 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}