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,
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 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}