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