1use std::collections::HashMap;
9use std::io::{Read, Write};
10use std::net::{Shutdown, TcpStream};
11use std::path::Path;
12
13use sley_core::{Capability, GitError, ObjectFormat, ObjectId, Result};
14use sley_fetch::{install_upload_pack_raw_promisor_response, install_upload_pack_raw_response};
15use sley_odb::{FileObjectDatabase, build_reachable_pack, collect_reachable_object_ids};
16use sley_protocol::{
17 GitService, ProtocolV2FetchShallowInfo, ReceivePackCommand, ReceivePackFeatures,
18 ReceivePackPushRequestOptions, RefAdvertisement, UploadPackFeatures,
19 UploadPackNegotiationRequest, UploadPackRawPackfileResponse, UploadPackRequest,
20 build_receive_pack_push_request, parse_receive_pack_features, parse_refspec,
21 parse_upload_pack_features, plan_push_commands, read_receive_pack_report_status,
22 read_ref_advertisement_set, read_upload_pack_raw_packfile_response,
23 read_upload_pack_shallow_info_and_raw_packfile_response, write_receive_pack_push_request,
24 write_upload_pack_negotiation_request, write_upload_pack_request,
25};
26use sley_refs::FileRefStore;
27use sley_transport::{RemoteTransport, RemoteUrl, ServiceRequest, write_service_request};
28
29use crate::{PushOutcome, PushRequest};
30
31const GIT_DAEMON_PORT: u16 = 9418;
32
33pub(crate) struct GitPushRequest<'a> {
34 pub git_dir: &'a Path,
35 pub common_git_dir: &'a Path,
36 pub format: ObjectFormat,
37 pub remote: &'a RemoteUrl,
38 pub refspecs: &'a [String],
39 pub force: bool,
40}
41
42pub(crate) struct GitPushCommandsRequest<'a> {
43 pub common_git_dir: &'a Path,
44 pub format: ObjectFormat,
45 pub remote: &'a RemoteUrl,
46 pub command_forces: Vec<(ReceivePackCommand, bool)>,
47 pub pack_objects: Vec<ObjectId>,
48}
49
50pub(crate) struct GitPushPlan {
51 pub(crate) commands: Vec<ReceivePackCommand>,
52 pub(crate) pack_objects: Vec<ObjectId>,
53 stream: Option<TcpStream>,
54 features: ReceivePackFeatures,
55 advertisements: Vec<RefAdvertisement>,
56}
57
58pub struct GitFetchPackRequest<'a> {
59 pub git_dir: &'a Path,
60 pub format: ObjectFormat,
61 pub remote: &'a RemoteUrl,
62 pub features: &'a UploadPackFeatures,
63 pub wants: Vec<ObjectId>,
64 pub shallow: Vec<ObjectId>,
65 pub deepen: Option<u32>,
66 pub promisor: bool,
67}
68
69pub(crate) fn ls_remote_git(
70 remote: &RemoteUrl,
71 filter: &crate::ls_remote::LsRemoteFilter,
72 matches: &dyn Fn(&str) -> bool,
73) -> Result<(Vec<crate::ls_remote::LsRemoteRecord>, ObjectFormat)> {
74 let mut stream = connect_git_service(remote, GitService::UploadPack)?;
75 let set = read_ref_advertisement_set(ObjectFormat::Sha1, &mut stream)?;
76 let features = set
77 .refs
78 .first()
79 .map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
80 .transpose()?
81 .unwrap_or_default();
82 let format = features.object_format.unwrap_or(ObjectFormat::Sha1);
83 if format != ObjectFormat::Sha1 {
84 return Err(GitError::Unsupported(format!(
85 "git:// ls-remote currently supports SHA-1 advertisements, got {}",
86 format.name()
87 )));
88 }
89 let symrefs = features
90 .symrefs
91 .iter()
92 .filter_map(|symref| symref.split_once(':'))
93 .map(|(name, target)| (name.to_string(), target.to_string()))
94 .collect::<HashMap<_, _>>();
95 let mut records = Vec::new();
96 for advertisement in set.refs {
97 if advertisement.oid.is_null() {
98 continue;
99 }
100 if filter.refs_only && (advertisement.name == "HEAD" || advertisement.name.ends_with("^{}"))
101 {
102 continue;
103 }
104 let is_head = advertisement.name.starts_with("refs/heads/");
105 let is_tag = advertisement.name.starts_with("refs/tags/");
106 if (filter.heads || filter.tags) && !((filter.heads && is_head) || (filter.tags && is_tag))
107 {
108 continue;
109 }
110 if !matches(&advertisement.name) {
111 continue;
112 }
113 records.push(crate::ls_remote::LsRemoteRecord {
114 oid: advertisement.oid,
115 symref: symrefs.get(&advertisement.name).cloned(),
116 name: advertisement.name,
117 });
118 }
119 Ok((records, format))
120}
121
122pub fn git_upload_pack_advertisements(
123 remote: &RemoteUrl,
124 format: ObjectFormat,
125) -> Result<(Vec<RefAdvertisement>, UploadPackFeatures)> {
126 let mut stream = connect_git_service(remote, GitService::UploadPack)?;
127 let set = read_ref_advertisement_set(format, &mut stream)?;
128 let features = set
129 .refs
130 .first()
131 .map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
132 .transpose()?
133 .unwrap_or_default();
134 Ok((set.refs, features))
135}
136
137pub fn install_fetch_pack_via_git_upload_pack(
138 request: GitFetchPackRequest<'_>,
139) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
140 if request.wants.is_empty() {
141 return Ok(Vec::new());
142 }
143 let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
144 if request.deepen.is_none() && all_wants_present(&local_db, &request.wants)? {
145 return Ok(Vec::new());
146 }
147 let upload_request = UploadPackRequest {
148 wants: request.wants,
149 capabilities: shallow_request_capabilities(request.deepen),
150 shallow: request.shallow,
151 deepen: request.deepen,
152 ..UploadPackRequest::default()
153 };
154 let haves = crate::local::local_have_oids(request.git_dir, request.format)?;
155 let (shallow_info, response) = if request.deepen.is_some() {
156 git_upload_pack_shallow_fetch_response(
157 request.remote,
158 request.format,
159 request.features,
160 upload_request,
161 haves,
162 )?
163 } else {
164 let response = git_upload_pack_fetch_response(
165 request.remote,
166 request.format,
167 request.features,
168 upload_request,
169 haves,
170 )?;
171 (Vec::new(), response)
172 };
173 if request.promisor {
174 install_upload_pack_raw_promisor_response(&response, &local_db)?;
175 } else {
176 install_upload_pack_raw_response(&response, &local_db)?;
177 }
178 Ok(shallow_info)
179}
180
181pub fn git_upload_pack_fetch_response(
182 remote: &RemoteUrl,
183 format: ObjectFormat,
184 _features: &UploadPackFeatures,
185 request: UploadPackRequest,
186 haves: Vec<ObjectId>,
187) -> Result<UploadPackRawPackfileResponse> {
188 let (_shallow, response) =
189 git_upload_pack_fetch_response_inner(remote, format, request, haves, false)?;
190 Ok(response)
191}
192
193pub fn git_upload_pack_shallow_fetch_response(
194 remote: &RemoteUrl,
195 format: ObjectFormat,
196 _features: &UploadPackFeatures,
197 request: UploadPackRequest,
198 haves: Vec<ObjectId>,
199) -> Result<(
200 Vec<ProtocolV2FetchShallowInfo>,
201 UploadPackRawPackfileResponse,
202)> {
203 git_upload_pack_fetch_response_inner(remote, format, request, haves, true)
204}
205
206fn git_upload_pack_fetch_response_inner(
207 remote: &RemoteUrl,
208 format: ObjectFormat,
209 request: UploadPackRequest,
210 haves: Vec<ObjectId>,
211 expect_shallow_info: bool,
212) -> Result<(
213 Vec<ProtocolV2FetchShallowInfo>,
214 UploadPackRawPackfileResponse,
215)> {
216 let mut stream = connect_git_service(remote, GitService::UploadPack)?;
217 read_ref_advertisement_set(format, &mut stream)?;
218 write_upload_pack_request(&mut stream, Some(&request))?;
219 write_upload_pack_negotiation_request(
220 &mut stream,
221 &UploadPackNegotiationRequest { haves, done: true },
222 )?;
223 stream.flush()?;
224 let _ = stream.shutdown(Shutdown::Write);
225 if expect_shallow_info {
226 read_upload_pack_shallow_info_and_raw_packfile_response(format, &mut stream)
227 } else {
228 Ok((
229 Vec::new(),
230 read_upload_pack_raw_packfile_response(format, &mut stream)?,
231 ))
232 }
233}
234
235pub(crate) fn plan_push_git(request: GitPushRequest<'_>) -> Result<GitPushPlan> {
236 let GitPushRequest {
237 git_dir,
238 common_git_dir,
239 format,
240 remote,
241 refspecs,
242 force,
243 } = request;
244 let (stream, advertisements, features) = receive_pack_advertisements(remote, format)?;
245
246 let local_store = FileRefStore::new(git_dir, format);
247 let local_refs = crate::push::local_push_source_refs(&local_store, format)?;
248 let parsed_refspecs = refspecs
249 .iter()
250 .map(|refspec| parse_refspec(&crate::push::normalize_push_refspec(refspec)))
251 .collect::<Result<Vec<_>>>()?;
252 let mut command_forces = Vec::new();
253 for refspec in &parsed_refspecs {
254 for command in plan_push_commands(
255 format,
256 &local_refs,
257 &advertisements,
258 std::slice::from_ref(refspec),
259 )? {
260 command_forces.push((command, force || refspec.force));
261 }
262 }
263 let commands = command_forces
264 .iter()
265 .map(|(command, _)| command.clone())
266 .collect::<Vec<_>>();
267 if commands.is_empty() {
268 let _ = stream.shutdown(Shutdown::Both);
269 return Ok(GitPushPlan {
270 commands,
271 pack_objects: Vec::new(),
272 stream: None,
273 features,
274 advertisements,
275 });
276 }
277
278 let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
279 crate::push::reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
280 Ok(GitPushPlan {
281 commands,
282 pack_objects: Vec::new(),
283 stream: Some(stream),
284 features,
285 advertisements,
286 })
287}
288
289pub(crate) fn plan_push_git_commands(request: GitPushCommandsRequest<'_>) -> Result<GitPushPlan> {
290 let GitPushCommandsRequest {
291 common_git_dir,
292 format,
293 remote,
294 command_forces,
295 pack_objects,
296 } = request;
297 let (stream, advertisements, features) = receive_pack_advertisements(remote, format)?;
298 let commands = command_forces
299 .iter()
300 .map(|(command, _)| command.clone())
301 .collect::<Vec<_>>();
302 if commands.is_empty() {
303 let _ = stream.shutdown(Shutdown::Both);
304 return Ok(GitPushPlan {
305 commands,
306 pack_objects,
307 stream: None,
308 features,
309 advertisements,
310 });
311 }
312
313 let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
314 crate::push::reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
315 Ok(GitPushPlan {
316 commands,
317 pack_objects,
318 stream: Some(stream),
319 features,
320 advertisements,
321 })
322}
323
324pub(crate) fn execute_push_git_plan(
325 request: PushRequest<'_>,
326 mut plan: GitPushPlan,
327) -> Result<PushOutcome> {
328 if plan.commands.is_empty() {
329 return Ok(PushOutcome::default());
330 }
331 let mut stream = plan
332 .stream
333 .take()
334 .ok_or_else(|| GitError::Command("git:// receive-pack stream was not available".into()))?;
335 let commands = plan.commands.clone();
336 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
337 let remote_excluded_tips =
338 crate::remote_advertisement_tips_known_to_local(&local_db, &plan.advertisements)?;
339 let remote_excluded =
340 collect_reachable_object_ids(&local_db, request.format, remote_excluded_tips)?;
341 let starts = crate::pack::push_pack_roots(&commands, &plan.pack_objects);
342 let packfile = build_reachable_pack(&local_db, request.format, starts, &remote_excluded)?
343 .map(|pack| pack.pack)
344 .unwrap_or_default();
345 let receive_request = build_receive_pack_push_request(
346 &plan.features,
347 commands.clone(),
348 packfile,
349 ReceivePackPushRequestOptions {
350 report_status: plan.features.report_status,
351 ofs_delta: plan.features.ofs_delta,
352 quiet: request.options.quiet && plan.features.quiet,
353 object_format: plan
354 .features
355 .object_format
356 .filter(|_| request.format != ObjectFormat::Sha1),
357 ..ReceivePackPushRequestOptions::default()
358 },
359 )?;
360 write_receive_pack_push_request(&mut stream, &receive_request)?;
361 stream.flush()?;
362 let _ = stream.shutdown(Shutdown::Write);
363
364 let report = if plan.features.report_status {
365 let report = read_receive_pack_report_status(&mut stream)?;
366 crate::push::validate_receive_pack_report(&report)?;
367 Some(report)
368 } else {
369 let mut sink = Vec::new();
370 stream.read_to_end(&mut sink)?;
371 None
372 };
373 Ok(PushOutcome { commands, report })
374}
375
376fn receive_pack_advertisements(
377 remote: &RemoteUrl,
378 format: ObjectFormat,
379) -> Result<(TcpStream, Vec<RefAdvertisement>, ReceivePackFeatures)> {
380 let mut stream = connect_git_service(remote, GitService::ReceivePack)?;
381 let advertisement_set = read_ref_advertisement_set(format, &mut stream)?;
382 let features = advertisement_set
383 .refs
384 .first()
385 .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
386 .transpose()?
387 .unwrap_or_default();
388 if let Some(remote_format) = features.object_format {
389 if remote_format != format {
390 return Err(GitError::InvalidObjectId(format!(
391 "remote repository uses {}, local repository uses {}",
392 remote_format.name(),
393 format.name()
394 )));
395 }
396 } else if format != ObjectFormat::Sha1 {
397 return Err(GitError::InvalidObjectId(format!(
398 "remote repository did not advertise object-format for {} push",
399 format.name()
400 )));
401 }
402 Ok((stream, advertisement_set.refs, features))
403}
404
405fn connect_git_service(remote: &RemoteUrl, service: GitService) -> Result<TcpStream> {
406 if remote.transport != RemoteTransport::Git {
407 return Err(GitError::InvalidFormat(
408 "git:// service requires a git remote".into(),
409 ));
410 }
411 let host = remote
412 .host
413 .as_deref()
414 .ok_or_else(|| GitError::InvalidFormat("git:// remote is missing a host".into()))?;
415 let port = remote.port.unwrap_or(GIT_DAEMON_PORT);
416 let mut stream = TcpStream::connect((host, port))?;
417 let request = ServiceRequest {
418 service,
419 path: remote.path.clone(),
420 host: Some(git_host_parameter(remote, host, port)),
421 parameters: Vec::new(),
422 protocol: None,
423 extra_parameters: Vec::new(),
424 };
425 write_service_request(&mut stream, &request)?;
426 stream.flush()?;
427 Ok(stream)
428}
429
430fn git_host_parameter(remote: &RemoteUrl, host: &str, port: u16) -> String {
431 match remote.port {
432 Some(_) if host.contains(':') && !host.starts_with('[') => format!("[{host}]:{port}"),
433 Some(_) => format!("{host}:{port}"),
434 None => host.to_string(),
435 }
436}
437
438fn shallow_request_capabilities(deepen: Option<u32>) -> Vec<Capability> {
439 if deepen.is_some() {
440 vec![Capability {
441 name: "shallow".into(),
442 value: None,
443 }]
444 } else {
445 Vec::new()
446 }
447}
448
449fn all_wants_present(db: &FileObjectDatabase, wants: &[ObjectId]) -> Result<bool> {
450 for want in wants {
451 if !db.contains(want)? {
452 return Ok(false);
453 }
454 }
455 Ok(true)
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461 use std::net::TcpListener;
462 use std::thread;
463
464 use sley_protocol::{ProtocolVersion, RefAdvertisement, RefAdvertisementSet};
465 use sley_transport::read_service_request;
466
467 #[test]
468 fn ls_remote_git_sends_daemon_request_and_reads_advertisements() {
469 let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind daemon");
470 let port = listener.local_addr().expect("local addr").port();
471 let tip = ObjectId::from_hex(
472 ObjectFormat::Sha1,
473 "1111111111111111111111111111111111111111",
474 )
475 .expect("oid");
476 let server = thread::spawn(move || {
477 let (mut stream, _) = listener.accept().expect("accept daemon client");
478 let request = read_service_request(&mut stream).expect("service request");
479 assert_eq!(request.service, GitService::UploadPack);
480 assert_eq!(request.path, "/repo.git");
481 let expected_host = format!("127.0.0.1:{port}");
482 assert_eq!(request.host.as_deref(), Some(expected_host.as_str()));
483 sley_protocol::write_ref_advertisement_set(
484 &mut stream,
485 &RefAdvertisementSet {
486 protocol: ProtocolVersion::V0,
487 refs: vec![
488 RefAdvertisement {
489 oid: tip,
490 name: "HEAD".into(),
491 capabilities: vec![Capability {
492 name: "symref".into(),
493 value: Some("HEAD:refs/heads/main".into()),
494 }],
495 },
496 RefAdvertisement {
497 oid: tip,
498 name: "refs/heads/main".into(),
499 capabilities: Vec::new(),
500 },
501 ],
502 shallow: Vec::new(),
503 },
504 )
505 .expect("write advertisements");
506 });
507 let remote = RemoteUrl {
508 transport: RemoteTransport::Git,
509 user: None,
510 password: None,
511 host: Some("127.0.0.1".into()),
512 port: Some(port),
513 path: "/repo.git".into(),
514 };
515 let (records, format) = ls_remote_git(
516 &remote,
517 &crate::ls_remote::LsRemoteFilter::default(),
518 &|_| true,
519 )
520 .expect("ls-remote");
521
522 server.join().expect("server thread");
523 assert_eq!(format, ObjectFormat::Sha1);
524 assert_eq!(records.len(), 2);
525 assert_eq!(records[0].name, "HEAD");
526 assert_eq!(records[0].symref.as_deref(), Some("refs/heads/main"));
527 assert_eq!(records[1].name, "refs/heads/main");
528 assert_eq!(records[1].oid, tip);
529 }
530}