1use std::collections::HashMap;
22#[cfg(feature = "http")]
23use std::io::Read;
24use std::path::{Path, PathBuf};
25
26use sley_config::GitConfig;
27use sley_core::{GitError, ObjectFormat, ObjectId, Result};
28use sley_object::{Commit, ObjectType};
29use sley_odb::{FileObjectDatabase, ObjectReader, collect_reachable_object_ids};
30#[cfg(feature = "http")]
31use sley_protocol::{
32 GitService, ReceivePackFeatures, ReceivePackPushRequestOptions, parse_receive_pack_features,
33 read_receive_pack_report_status, smart_http_rpc_request_content_type,
34 smart_http_rpc_result_content_type,
35};
36use sley_protocol::{
37 PushSourceRef, ReceivePackCommand, ReceivePackCommandStatus, ReceivePackPushRequest,
38 ReceivePackReportStatus, ReceivePackRequest, ReceivePackUnpackStatus, RefAdvertisement,
39 RefSpec, parse_refspec, plan_push_commands,
40};
41
42use crate::pack::push_pack_roots;
43#[cfg(feature = "http")]
44use crate::pack::{PushPackRequest, write_receive_pack_body};
45use sley_refs::{FileRefStore, Ref, RefTarget};
46use sley_transport::RemoteUrl;
47#[cfg(feature = "http")]
48use sley_transport::{HttpClient, HttpResponse, http_smart_rpc_url};
49
50use crate::{CredentialProvider, ProgressSink};
51
52pub enum PushDestination {
58 Http(RemoteUrl),
60 Ssh(RemoteUrl),
63 Git(RemoteUrl),
65 Local {
67 git_dir: PathBuf,
69 common_git_dir: PathBuf,
71 },
72}
73
74#[derive(Debug, Clone, Copy, Default)]
84pub struct PushOptions {
85 pub quiet: bool,
89 pub force: bool,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct PushCommand {
97 pub src: Option<ObjectId>,
99 pub dst: String,
101 pub expected_old: Option<ObjectId>,
105 pub force: bool,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum PushAction {
114 Create {
115 dst: String,
116 new: ObjectId,
117 },
118 Update {
119 dst: String,
120 old: ObjectId,
121 new: ObjectId,
122 },
123 Delete {
124 dst: String,
125 old: Option<ObjectId>,
126 },
127}
128
129impl From<PushAction> for PushCommand {
130 fn from(value: PushAction) -> Self {
131 match value {
132 PushAction::Create { dst, new } => Self {
133 src: Some(new),
134 dst,
135 expected_old: None,
136 force: false,
137 },
138 PushAction::Update { dst, old, new } => Self {
139 src: Some(new),
140 dst,
141 expected_old: Some(old),
142 force: false,
143 },
144 PushAction::Delete { dst, old } => Self {
145 src: None,
146 dst,
147 expected_old: old,
148 force: false,
149 },
150 }
151 }
152}
153
154#[derive(Debug, Clone)]
157pub struct PushActionPlan {
158 pub commands: Vec<PushCommand>,
159 pub pack_objects: Vec<ObjectId>,
160 pub options: PushOptions,
161}
162
163impl PushActionPlan {
164 pub fn from_actions(actions: Vec<PushAction>, options: PushOptions) -> Self {
165 Self {
166 commands: actions.into_iter().map(PushCommand::from).collect(),
167 pack_objects: Vec::new(),
168 options,
169 }
170 }
171
172 pub fn from_commands(commands: Vec<PushCommand>, options: PushOptions) -> Self {
173 Self {
174 commands,
175 pack_objects: Vec::new(),
176 options,
177 }
178 }
179
180 pub fn from_commands_and_infer_pack_roots(
181 commands: Vec<PushCommand>,
182 options: PushOptions,
183 ) -> Self {
184 let mut pack_objects = Vec::new();
185 for command in &commands {
186 let Some(src) = command.src.as_ref() else {
187 continue;
188 };
189 if !pack_objects.contains(src) {
190 pack_objects.push(*src);
191 }
192 }
193 Self {
194 commands,
195 pack_objects,
196 options,
197 }
198 }
199}
200
201#[derive(Debug, Clone, Default)]
203pub struct PushOutcome {
204 pub commands: Vec<ReceivePackCommand>,
209 pub report: Option<ReceivePackReportStatus>,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
221pub enum PushRefStatus {
222 Ok,
224 UpToDate,
226 RejectNonFastForward,
228 RejectStale,
230 RejectRemoteUpdated,
232 RejectAlreadyExists,
234 RemoteReject(String),
236 AtomicPushFailed,
238}
239
240#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct PushReportRef {
246 pub src: Option<String>,
249 pub dst: String,
251 pub old_id: ObjectId,
253 pub new_id: ObjectId,
255 pub forced: bool,
257 pub status: PushRefStatus,
259}
260
261impl PushReportRef {
262 pub fn is_deletion(&self) -> bool {
264 self.new_id.is_null()
265 }
266
267 pub fn had_error(&self) -> bool {
270 !matches!(self.status, PushRefStatus::Ok | PushRefStatus::UpToDate)
271 }
272}
273
274#[derive(Debug, Clone, Default, PartialEq, Eq)]
278pub struct PushStatusReport {
279 pub refs: Vec<PushReportRef>,
281}
282
283impl PushStatusReport {
284 pub fn had_errors(&self) -> bool {
286 self.refs.iter().any(PushReportRef::had_error)
287 }
288
289 pub fn refs_pushed(&self) -> bool {
292 self.refs.iter().any(|reference| {
293 reference.old_id != reference.new_id && matches!(reference.status, PushRefStatus::Ok)
294 })
295 }
296}
297
298#[derive(Clone, Copy)]
300pub struct PushRequest<'a> {
301 pub git_dir: &'a Path,
303 pub common_git_dir: &'a Path,
305 pub format: ObjectFormat,
307 pub config: &'a GitConfig,
309 pub remote: &'a str,
311 pub destination: &'a PushDestination,
313 pub refspecs: &'a [String],
315 pub options: &'a PushOptions,
317}
318
319#[derive(Clone, Copy)]
321pub struct PushActionRequest<'a> {
322 pub git_dir: &'a Path,
324 pub common_git_dir: &'a Path,
326 pub format: ObjectFormat,
328 pub config: &'a GitConfig,
330 pub remote: &'a str,
332 pub destination: &'a PushDestination,
334 pub plan: &'a PushActionPlan,
336}
337
338pub struct PushServices<'a> {
340 pub credentials: &'a mut dyn CredentialProvider,
342 pub progress: &'a mut dyn ProgressSink,
344}
345
346pub struct PushPlan {
349 pub commands: Vec<ReceivePackCommand>,
351 execution: PushExecution,
352}
353
354enum PushExecution {
355 Noop,
356 #[cfg(feature = "http")]
357 Http {
358 remote_url: RemoteUrl,
359 features: ReceivePackFeatures,
360 advertisements: Vec<RefAdvertisement>,
361 pack_objects: Vec<ObjectId>,
362 },
363 Ssh(crate::ssh::SshPushPlan),
364 Git(crate::git::GitPushPlan),
365 Local {
366 remote_git_dir: PathBuf,
367 remote_common_git_dir: PathBuf,
368 remote_refs: Vec<RefAdvertisement>,
369 command_forces: Vec<(ReceivePackCommand, bool)>,
370 pack_objects: Vec<ObjectId>,
371 },
372}
373
374pub fn push(request: PushRequest<'_>, mut services: PushServices<'_>) -> Result<PushOutcome> {
389 let plan = plan_push(request, &mut services)?;
390 execute_push_plan(request, &mut services, plan)
391}
392
393pub fn push_actions(
395 request: PushActionRequest<'_>,
396 mut services: PushServices<'_>,
397) -> Result<PushOutcome> {
398 let plan = plan_push_actions(request, &mut services)?;
399 execute_push_action_plan(request, &mut services, plan)
400}
401
402pub fn plan_push(request: PushRequest<'_>, services: &mut PushServices<'_>) -> Result<PushPlan> {
405 let _ = &mut services.progress;
410 crate::protocol::check_transport_allowed(
411 scheme_for_push_destination(request.destination),
412 Some(request.config),
413 None,
414 )
415 .map_err(crate::protocol::transport_policy_git_error)?;
416 match request.destination {
417 #[cfg(feature = "http")]
418 PushDestination::Http(remote_url) => plan_push_http(PushHttpRequest {
419 git_dir: request.git_dir,
420 common_git_dir: request.common_git_dir,
421 format: request.format,
422 remote_url,
423 refspecs: request.refspecs,
424 options: request.options,
425 credentials: services.credentials,
426 }),
427 #[cfg(not(feature = "http"))]
428 PushDestination::Http(_) => Err(GitError::Unsupported(
429 "HTTP transport is not enabled in this build".into(),
430 )),
431 PushDestination::Ssh(remote_url) => {
432 let plan = crate::ssh::plan_push_ssh(crate::ssh::SshPushRequest {
433 git_dir: request.git_dir,
434 common_git_dir: request.common_git_dir,
435 format: request.format,
436 remote: remote_url,
437 refspecs: request.refspecs,
438 force: request.options.force,
439 })?;
440 let commands = plan.commands.clone();
441 let execution = if commands.is_empty() {
442 PushExecution::Noop
443 } else {
444 PushExecution::Ssh(plan)
445 };
446 Ok(PushPlan {
447 commands,
448 execution,
449 })
450 }
451 PushDestination::Git(remote_url) => {
452 let plan = crate::git::plan_push_git(crate::git::GitPushRequest {
453 git_dir: request.git_dir,
454 common_git_dir: request.common_git_dir,
455 format: request.format,
456 remote: remote_url,
457 refspecs: request.refspecs,
458 force: request.options.force,
459 })?;
460 let commands = plan.commands.clone();
461 let execution = if commands.is_empty() {
462 PushExecution::Noop
463 } else {
464 PushExecution::Git(plan)
465 };
466 Ok(PushPlan {
467 commands,
468 execution,
469 })
470 }
471 PushDestination::Local {
472 git_dir: remote_git_dir,
473 common_git_dir: remote_common_git_dir,
474 } => plan_push_local(PushLocalRequest {
475 git_dir: request.git_dir,
476 common_git_dir: request.common_git_dir,
477 format: request.format,
478 remote: request.remote,
479 remote_git_dir,
480 remote_common_git_dir,
481 refspecs: request.refspecs,
482 options: request.options,
483 }),
484 }
485}
486
487pub fn plan_push_actions(
490 request: PushActionRequest<'_>,
491 services: &mut PushServices<'_>,
492) -> Result<PushPlan> {
493 let _ = &mut services.progress;
494 crate::protocol::check_transport_allowed(
495 scheme_for_push_destination(request.destination),
496 Some(request.config),
497 None,
498 )
499 .map_err(crate::protocol::transport_policy_git_error)?;
500 let commands = receive_pack_commands_from_action_plan(request.format, request.plan)?;
501 let command_forces = commands
502 .iter()
503 .cloned()
504 .zip(request.plan.commands.iter())
505 .map(|(command, planned)| (command, request.plan.options.force || planned.force))
506 .collect::<Vec<_>>();
507 match request.destination {
508 #[cfg(feature = "http")]
509 PushDestination::Http(remote_url) => {
510 let client = crate::http::new_http_client();
511 let discovered = crate::http::http_service_advertisements(
512 &client,
513 remote_url,
514 request.format,
515 GitService::ReceivePack,
516 services.credentials,
517 )?;
518 let advertisement_set = discovered.set;
519 let features = advertised_receive_pack_features(&advertisement_set.refs)?;
520 verify_remote_object_format(&features, request.format)?;
521 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
522 reject_non_fast_forward_pushes(&local_db, request.format, &command_forces)?;
523 let execution = if commands.is_empty() {
524 PushExecution::Noop
525 } else {
526 PushExecution::Http {
527 remote_url: remote_url.clone(),
528 features,
529 advertisements: advertisement_set.refs,
530 pack_objects: request.plan.pack_objects.clone(),
531 }
532 };
533 Ok(PushPlan {
534 commands,
535 execution,
536 })
537 }
538 #[cfg(not(feature = "http"))]
539 PushDestination::Http(_) => Err(GitError::Unsupported(
540 "HTTP transport is not enabled in this build".into(),
541 )),
542 PushDestination::Ssh(remote_url) => {
543 let plan = crate::ssh::plan_push_ssh_commands(crate::ssh::SshPushCommandsRequest {
544 common_git_dir: request.common_git_dir,
545 format: request.format,
546 remote: remote_url,
547 command_forces: command_forces.clone(),
548 pack_objects: request.plan.pack_objects.clone(),
549 })?;
550 let commands = plan.commands.clone();
551 let execution = if commands.is_empty() {
552 PushExecution::Noop
553 } else {
554 PushExecution::Ssh(plan)
555 };
556 Ok(PushPlan {
557 commands,
558 execution,
559 })
560 }
561 PushDestination::Git(remote_url) => {
562 let plan = crate::git::plan_push_git_commands(crate::git::GitPushCommandsRequest {
563 common_git_dir: request.common_git_dir,
564 format: request.format,
565 remote: remote_url,
566 command_forces: command_forces.clone(),
567 pack_objects: request.plan.pack_objects.clone(),
568 })?;
569 let commands = plan.commands.clone();
570 let execution = if commands.is_empty() {
571 PushExecution::Noop
572 } else {
573 PushExecution::Git(plan)
574 };
575 Ok(PushPlan {
576 commands,
577 execution,
578 })
579 }
580 PushDestination::Local {
581 git_dir: remote_git_dir,
582 common_git_dir: remote_common_git_dir,
583 } => {
584 let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
585 if remote_format != request.format {
586 return Err(GitError::InvalidObjectId(format!(
587 "remote repository uses {}, local repository uses {}",
588 remote_format.name(),
589 request.format.name()
590 )));
591 }
592 let remote_refs =
593 crate::local::local_fetch_advertisements(remote_git_dir, request.format)?;
594 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
595 reject_non_fast_forward_pushes(&local_db, request.format, &command_forces)?;
596 let execution = if commands.is_empty() {
597 PushExecution::Noop
598 } else {
599 PushExecution::Local {
600 remote_git_dir: remote_git_dir.to_path_buf(),
601 remote_common_git_dir: remote_common_git_dir.to_path_buf(),
602 remote_refs,
603 command_forces,
604 pack_objects: request.plan.pack_objects.clone(),
605 }
606 };
607 Ok(PushPlan {
608 commands,
609 execution,
610 })
611 }
612 }
613}
614
615fn scheme_for_push_destination(destination: &PushDestination) -> &'static str {
616 match destination {
617 PushDestination::Http(remote) => crate::protocol::transport_scheme_for_remote(remote),
618 PushDestination::Ssh(remote) => crate::protocol::transport_scheme_for_remote(remote),
619 PushDestination::Git(remote) => crate::protocol::transport_scheme_for_remote(remote),
620 PushDestination::Local { .. } => "file",
621 }
622}
623
624pub fn execute_push_plan(
626 request: PushRequest<'_>,
627 services: &mut PushServices<'_>,
628 plan: PushPlan,
629) -> Result<PushOutcome> {
630 let _ = (request.config, request.remote);
631 let _ = &mut services.progress;
632 if plan.commands.is_empty() {
633 return Ok(PushOutcome::default());
634 }
635 match plan.execution {
636 PushExecution::Noop => Ok(PushOutcome::default()),
637 #[cfg(feature = "http")]
638 PushExecution::Http {
639 remote_url,
640 features,
641 advertisements,
642 pack_objects,
643 } => execute_push_http(
644 request,
645 services.credentials,
646 plan.commands,
647 remote_url,
648 features,
649 advertisements,
650 pack_objects,
651 ),
652 PushExecution::Ssh(plan) => crate::ssh::execute_push_ssh_plan(request, plan),
653 PushExecution::Git(plan) => crate::git::execute_push_git_plan(request, plan),
654 PushExecution::Local {
655 remote_git_dir,
656 remote_common_git_dir,
657 remote_refs,
658 command_forces,
659 pack_objects,
660 } => execute_push_local(
661 request,
662 plan.commands,
663 remote_git_dir,
664 remote_common_git_dir,
665 remote_refs,
666 command_forces,
667 pack_objects,
668 ),
669 }
670}
671
672pub fn execute_push_action_plan(
674 request: PushActionRequest<'_>,
675 services: &mut PushServices<'_>,
676 plan: PushPlan,
677) -> Result<PushOutcome> {
678 let refspecs: &[String] = &[];
679 execute_push_plan(
680 PushRequest {
681 git_dir: request.git_dir,
682 common_git_dir: request.common_git_dir,
683 format: request.format,
684 config: request.config,
685 remote: request.remote,
686 destination: request.destination,
687 refspecs,
688 options: &request.plan.options,
689 },
690 services,
691 plan,
692 )
693}
694
695#[cfg(feature = "http")]
698struct PushHttpRequest<'a> {
699 git_dir: &'a Path,
700 common_git_dir: &'a Path,
701 format: ObjectFormat,
702 remote_url: &'a RemoteUrl,
703 refspecs: &'a [String],
704 options: &'a PushOptions,
705 credentials: &'a mut dyn CredentialProvider,
706}
707
708#[cfg(feature = "http")]
709fn plan_push_http(request: PushHttpRequest<'_>) -> Result<PushPlan> {
710 let PushHttpRequest {
711 git_dir,
712 common_git_dir,
713 format,
714 remote_url,
715 refspecs,
716 options,
717 credentials,
718 } = request;
719 let client = crate::http::new_http_client();
720 let discovered = crate::http::http_service_advertisements(
721 &client,
722 remote_url,
723 format,
724 GitService::ReceivePack,
725 credentials,
726 )?;
727 let advertisement_set = discovered.set;
728 let features = advertised_receive_pack_features(&advertisement_set.refs)?;
729 verify_remote_object_format(&features, format)?;
730
731 let local_store = FileRefStore::new(git_dir, format);
732 let mut local_refs = local_push_source_refs(&local_store, format)?;
733 add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
734 let command_forces = plan_push_command_forces(
735 format,
736 &local_refs,
737 &advertisement_set.refs,
738 refspecs,
739 options.force,
740 )?;
741 let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
742 reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
743 let commands = commands_from_forces(&command_forces);
744 let execution = if commands.is_empty() {
745 PushExecution::Noop
746 } else {
747 PushExecution::Http {
748 remote_url: remote_url.clone(),
749 features,
750 advertisements: advertisement_set.refs,
751 pack_objects: Vec::new(),
752 }
753 };
754 Ok(PushPlan {
755 commands,
756 execution,
757 })
758}
759
760#[cfg(feature = "http")]
761fn execute_push_http(
762 request: PushRequest<'_>,
763 credentials: &mut dyn CredentialProvider,
764 commands: Vec<ReceivePackCommand>,
765 remote_url: RemoteUrl,
766 features: ReceivePackFeatures,
767 advertisements: Vec<RefAdvertisement>,
768 pack_objects: Vec<ObjectId>,
769) -> Result<PushOutcome> {
770 let client = crate::http::new_http_client();
771 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
772 let pack_request = PushPackRequest {
773 local_db: &local_db,
774 format: request.format,
775 commands: &commands,
776 pack_objects: &pack_objects,
777 remote_advertisements: &advertisements,
778 features: &features,
779 options: receive_pack_push_options(&features, request.format, request.options.quiet),
780 thin: false,
781 };
782 let url = http_smart_rpc_url(&remote_url, GitService::ReceivePack)?;
783 let content_type = smart_http_rpc_request_content_type(GitService::ReceivePack)?;
784 let post_buffer = http_post_buffer(request.config);
785 let mut response = crate::http::http_send_with_auth(&remote_url, credentials, |auth| {
786 let headers = crate::http::http_authorization_headers(auth);
787 send_receive_pack_body(
788 &client,
789 &url,
790 &content_type,
791 &headers,
792 &pack_request,
793 post_buffer,
794 )
795 })?;
796 crate::http::http_check_status(&response, &url)?;
797 crate::http::http_validate_content_type(
798 &response,
799 &smart_http_rpc_result_content_type(GitService::ReceivePack)?,
800 )?;
801
802 let report = if features.report_status {
803 let report = read_receive_pack_report_status(&mut response.body)?;
804 validate_receive_pack_report(&report)?;
805 Some(report)
806 } else {
807 let mut sink = Vec::new();
808 response.body.read_to_end(&mut sink)?;
809 None
810 };
811 Ok(PushOutcome { commands, report })
812}
813
814#[cfg(feature = "http")]
820fn http_post_buffer(config: &GitConfig) -> usize {
821 const DEFAULT_POST_BUFFER: usize = 1 << 20;
822 config
823 .get("http", None, "postBuffer")
824 .and_then(parse_post_buffer)
825 .filter(|bytes| *bytes > 0)
826 .unwrap_or(DEFAULT_POST_BUFFER)
827}
828
829#[cfg(feature = "http")]
832fn parse_post_buffer(raw: &str) -> Option<usize> {
833 let raw = raw.trim();
834 let (digits, multiplier) = match raw.as_bytes().last() {
835 Some(b'k' | b'K') => (&raw[..raw.len() - 1], 1024usize),
836 Some(b'm' | b'M') => (&raw[..raw.len() - 1], 1024 * 1024),
837 Some(b'g' | b'G') => (&raw[..raw.len() - 1], 1024 * 1024 * 1024),
838 _ => (raw, 1),
839 };
840 digits
841 .trim()
842 .parse::<usize>()
843 .ok()
844 .and_then(|value| value.checked_mul(multiplier))
845}
846
847#[cfg(feature = "http")]
853fn send_receive_pack_body(
854 client: &dyn HttpClient,
855 url: &str,
856 content_type: &str,
857 headers: &[(&str, &str)],
858 pack_request: &PushPackRequest<'_>,
859 post_buffer: usize,
860) -> Result<HttpResponse> {
861 std::thread::scope(|scope| {
862 let (mut reader, writer) = std::io::pipe().map_err(|err| GitError::Io(err.to_string()))?;
863 let generator = scope.spawn(move || -> Result<()> {
864 let mut writer = writer;
867 write_receive_pack_body(pack_request, &mut writer)
868 });
869
870 let mut probe = Vec::new();
873 read_up_to(&mut reader, post_buffer.saturating_add(1), &mut probe)?;
874
875 if probe.len() <= post_buffer {
876 join_pack_generator(generator)?;
880 client.post(url, content_type, headers, &probe)
881 } else {
882 let response = {
887 let mut body = std::io::Cursor::new(probe).chain(reader);
888 client.post_reader(url, content_type, headers, &mut body)
889 };
890 let generation = join_pack_generator(generator);
891 match response {
892 Ok(response) => Ok(response),
895 Err(transport) => match generation {
896 Err(generation) => Err(generation),
897 Ok(()) => Err(transport),
898 },
899 }
900 }
901 })
902}
903
904#[cfg(feature = "http")]
907fn join_pack_generator(handle: std::thread::ScopedJoinHandle<'_, Result<()>>) -> Result<()> {
908 match handle.join() {
909 Ok(result) => result,
910 Err(_) => Err(GitError::Io(
911 "receive-pack body generator thread panicked".to_string(),
912 )),
913 }
914}
915
916#[cfg(feature = "http")]
918fn read_up_to(reader: &mut impl Read, cap: usize, out: &mut Vec<u8>) -> Result<()> {
919 let mut chunk = [0u8; 8192];
920 while out.len() < cap {
921 let want = (cap - out.len()).min(chunk.len());
922 let read = reader
923 .read(&mut chunk[..want])
924 .map_err(|err| GitError::Io(err.to_string()))?;
925 if read == 0 {
926 break;
927 }
928 out.extend_from_slice(&chunk[..read]);
929 }
930 Ok(())
931}
932
933struct PushLocalRequest<'a> {
937 git_dir: &'a Path,
938 common_git_dir: &'a Path,
939 format: ObjectFormat,
940 remote: &'a str,
941 remote_git_dir: &'a Path,
942 remote_common_git_dir: &'a Path,
943 refspecs: &'a [String],
944 options: &'a PushOptions,
945}
946
947fn plan_push_local(request: PushLocalRequest<'_>) -> Result<PushPlan> {
948 let PushLocalRequest {
949 git_dir,
950 common_git_dir,
951 format,
952 remote,
953 remote_git_dir,
954 remote_common_git_dir,
955 refspecs,
956 options,
957 } = request;
958 let _ = remote;
959 let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
960 if remote_format != format {
961 return Err(GitError::InvalidObjectId(format!(
962 "remote repository uses {}, local repository uses {}",
963 remote_format.name(),
964 format.name()
965 )));
966 }
967
968 let local_store = FileRefStore::new(git_dir, format);
969 let mut local_refs = local_push_source_refs(&local_store, format)?;
970 add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
971 let remote_refs = crate::local::local_fetch_advertisements(remote_git_dir, format)?;
972 let command_forces =
973 plan_push_command_forces(format, &local_refs, &remote_refs, refspecs, options.force)?;
974 let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
975 reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
976 let commands = commands_from_forces(&command_forces);
977 let execution = if commands.is_empty() {
978 PushExecution::Noop
979 } else {
980 PushExecution::Local {
981 remote_git_dir: remote_git_dir.to_path_buf(),
982 remote_common_git_dir: remote_common_git_dir.to_path_buf(),
983 remote_refs,
984 command_forces,
985 pack_objects: Vec::new(),
986 }
987 };
988 Ok(PushPlan {
989 commands,
990 execution,
991 })
992}
993
994fn execute_push_local(
995 request: PushRequest<'_>,
996 commands: Vec<ReceivePackCommand>,
997 remote_git_dir: PathBuf,
998 remote_common_git_dir: PathBuf,
999 remote_refs: Vec<RefAdvertisement>,
1000 _command_forces: Vec<(ReceivePackCommand, bool)>,
1001 pack_objects: Vec<ObjectId>,
1002) -> Result<PushOutcome> {
1003 let remote_excluded_tips = remote_refs
1004 .iter()
1005 .map(|reference| reference.oid)
1006 .collect::<Vec<_>>();
1007 let starts = push_pack_roots(&commands, &pack_objects);
1008 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
1009 let remote_db = FileObjectDatabase::from_git_dir(&remote_common_git_dir, request.format);
1010 let remote_excluded =
1011 collect_reachable_object_ids(&remote_db, request.format, remote_excluded_tips)?;
1012
1013 if remote_transfer_fsck_objects(&remote_common_git_dir) {
1018 fsck_pushed_objects(&local_db, request.format, &starts, &remote_excluded)?;
1019 }
1020 let packfile = if starts.is_empty() {
1021 Vec::new()
1022 } else {
1023 b"PACK".to_vec()
1024 };
1025 let receive_request = ReceivePackPushRequest {
1026 commands: ReceivePackRequest {
1027 shallow: Vec::new(),
1028 commands: commands.clone(),
1029 capabilities: Vec::new(),
1030 },
1031 push_options: None,
1032 packfile,
1033 };
1034 let report = crate::local::receive_pack_reachable_pack_into_local_repository(
1035 &remote_git_dir,
1036 request.format,
1037 &receive_request,
1038 &local_db,
1039 starts,
1040 remote_excluded,
1041 )?;
1042 validate_receive_pack_report(&report)?;
1043 Ok(PushOutcome {
1044 commands,
1045 report: Some(report),
1046 })
1047}
1048
1049fn remote_transfer_fsck_objects(remote_common_git_dir: &Path) -> bool {
1052 GitConfig::read(remote_common_git_dir.join("config"))
1053 .ok()
1054 .and_then(|config| config.get_bool("transfer", None, "fsckObjects"))
1055 .unwrap_or(false)
1056}
1057
1058fn fsck_pushed_objects(
1062 local_db: &FileObjectDatabase,
1063 format: ObjectFormat,
1064 starts: &[ObjectId],
1065 remote_excluded: &std::collections::HashSet<ObjectId>,
1066) -> Result<()> {
1067 if starts.is_empty() {
1068 return Ok(());
1069 }
1070 let new_objects: Vec<ObjectId> =
1071 collect_reachable_object_ids(local_db, format, starts.to_vec())?
1072 .into_iter()
1073 .filter(|oid| !remote_excluded.contains(oid))
1074 .collect();
1075 let report = sley_fsck::fsck_objects(local_db, format, [], new_objects);
1079 if report.is_ok() {
1080 return Ok(());
1081 }
1082 for issue in &report.issues {
1083 if issue.severity == sley_fsck::IssueSeverity::Error {
1084 eprintln!("fatal: {}", issue.message);
1085 }
1086 }
1087 Err(GitError::Exit(128))
1088}
1089
1090pub struct PushReportRequest<'a> {
1092 pub git_dir: &'a Path,
1094 pub common_git_dir: &'a Path,
1096 pub format: ObjectFormat,
1098 pub remote_git_dir: &'a Path,
1100 pub remote_common_git_dir: &'a Path,
1102 pub refspecs: &'a [String],
1104 pub force: bool,
1106 pub atomic: bool,
1108 pub dry_run: bool,
1110 pub force_with_lease: &'a [(String, Option<ObjectId>)],
1113 pub force_with_lease_default: bool,
1118 pub force_if_includes: bool,
1121 pub receive_config_overrides: &'a [(String, String)],
1124}
1125
1126pub fn push_local_with_report(
1134 request: PushReportRequest<'_>,
1135 _config: &GitConfig,
1136) -> Result<PushStatusReport> {
1137 let format = request.format;
1138 let remote_format = crate::object_format_for_git_dir(request.remote_common_git_dir)?;
1139 if remote_format != format {
1140 return Err(GitError::InvalidObjectId(format!(
1141 "remote repository uses {}, local repository uses {}",
1142 remote_format.name(),
1143 format.name()
1144 )));
1145 }
1146 let local_store = FileRefStore::new(request.git_dir, format);
1147 let mut local_refs = local_push_source_refs(&local_store, format)?;
1148 add_revision_push_sources(request.git_dir, format, request.refspecs, &mut local_refs);
1149 let remote_refs = crate::local::local_fetch_advertisements(request.remote_git_dir, format)?;
1150 let planned = plan_push_command_sources(
1151 format,
1152 &local_refs,
1153 &remote_refs,
1154 request.refspecs,
1155 request.force,
1156 )?;
1157 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, format);
1158 let remote_config =
1159 sley_config::read_repo_config(request.remote_git_dir, None).unwrap_or_default();
1160
1161 let mut refs: Vec<PushReportRef> = Vec::new();
1164 for plan in &planned {
1165 let status = classify_push_command(
1166 &local_db,
1167 format,
1168 plan,
1169 &request,
1170 &remote_config,
1171 request.remote_git_dir,
1172 )?;
1173 let stale_lease_overridden = plan.force && lease_expectation_mismatch(&request, plan);
1176 let forced = matches!(status, PushRefStatus::Ok)
1177 && !plan.command.old_id.is_null()
1178 && !plan.command.new_id.is_null()
1179 && (stale_lease_overridden
1180 || if plan.command.name.starts_with("refs/heads/") {
1181 !is_fast_forward(
1182 &local_db,
1183 format,
1184 &plan.command.old_id,
1185 &plan.command.new_id,
1186 )?
1187 } else {
1188 plan.force
1189 });
1190 refs.push(PushReportRef {
1191 src: plan.source.clone(),
1192 dst: plan.command.name.clone(),
1193 old_id: plan.command.old_id,
1194 new_id: plan.command.new_id,
1195 forced,
1196 status,
1197 });
1198 }
1199
1200 let any_local_reject = refs.iter().any(|reference| {
1201 matches!(
1202 reference.status,
1203 PushRefStatus::RejectNonFastForward
1204 | PushRefStatus::RejectStale
1205 | PushRefStatus::RejectRemoteUpdated
1206 | PushRefStatus::RejectAlreadyExists
1207 )
1208 });
1209
1210 if request.atomic && any_local_reject {
1214 for reference in &mut refs {
1215 if matches!(reference.status, PushRefStatus::Ok) {
1216 reference.status = PushRefStatus::AtomicPushFailed;
1217 }
1218 }
1219 return Ok(PushStatusReport { refs });
1220 }
1221
1222 if request.dry_run {
1223 return Ok(PushStatusReport { refs });
1224 }
1225
1226 let send: Vec<ReceivePackCommand> = refs
1228 .iter()
1229 .filter(|reference| {
1230 matches!(reference.status, PushRefStatus::Ok) && reference.old_id != reference.new_id
1231 })
1232 .map(|reference| ReceivePackCommand {
1233 old_id: reference.old_id,
1234 new_id: reference.new_id,
1235 name: reference.dst.clone(),
1236 })
1237 .collect();
1238
1239 if !send.is_empty() {
1240 let remote_excluded_tips: Vec<ObjectId> =
1241 remote_refs.iter().map(|reference| reference.oid).collect();
1242 let pack_objects: Vec<ObjectId> = Vec::new();
1243 let starts = push_pack_roots(&send, &pack_objects);
1244 let remote_db = FileObjectDatabase::from_git_dir(request.remote_common_git_dir, format);
1245 let remote_excluded =
1246 collect_reachable_object_ids(&remote_db, format, remote_excluded_tips)?;
1247 if remote_transfer_fsck_objects(request.remote_common_git_dir) {
1251 fsck_pushed_objects(&local_db, format, &starts, &remote_excluded)?;
1252 }
1253 let packfile = if starts.is_empty() {
1254 Vec::new()
1255 } else {
1256 b"PACK".to_vec()
1257 };
1258 let receive_request = ReceivePackPushRequest {
1259 commands: ReceivePackRequest {
1260 shallow: Vec::new(),
1261 commands: send.clone(),
1262 capabilities: Vec::new(),
1263 },
1264 push_options: None,
1265 packfile,
1266 };
1267 let report = crate::local::receive_pack_reachable_pack_into_local_repository(
1268 request.remote_git_dir,
1269 format,
1270 &receive_request,
1271 &local_db,
1272 starts,
1273 remote_excluded,
1274 )?;
1275 if let ReceivePackUnpackStatus::Error(message) = &report.unpack {
1277 for reference in &mut refs {
1278 if matches!(reference.status, PushRefStatus::Ok) {
1279 reference.status =
1280 PushRefStatus::RemoteReject(format!("unpacker error: {message}"));
1281 }
1282 }
1283 }
1284 for command_status in &report.commands {
1285 if let ReceivePackCommandStatus::Ng { name, message } = command_status {
1286 for reference in &mut refs {
1287 if reference.dst == *name && matches!(reference.status, PushRefStatus::Ok) {
1288 reference.status = PushRefStatus::RemoteReject(message.clone());
1289 }
1290 }
1291 }
1292 }
1293 }
1294
1295 Ok(PushStatusReport { refs })
1296}
1297
1298fn classify_push_command(
1302 local_db: &FileObjectDatabase,
1303 format: ObjectFormat,
1304 plan: &PlannedPushCommand,
1305 request: &PushReportRequest<'_>,
1306 config: &GitConfig,
1307 remote_git_dir: &Path,
1308) -> Result<PushRefStatus> {
1309 let command = &plan.command;
1310
1311 if receive_ref_is_hidden(config, request.receive_config_overrides, &command.name) {
1312 let reason = if command.new_id.is_null() {
1313 "deny deleting a hidden ref"
1314 } else {
1315 "deny updating a hidden ref"
1316 };
1317 return Ok(PushRefStatus::RemoteReject(reason.to_string()));
1318 }
1319
1320 if command.old_id == command.new_id && !command.new_id.is_null() {
1323 return Ok(PushRefStatus::UpToDate);
1324 }
1325
1326 if command.new_id.is_null() && !command.old_id.is_null() {
1327 if receive_config_bool(config, request.receive_config_overrides, "denydeletes")
1328 .unwrap_or(false)
1329 {
1330 return Ok(PushRefStatus::RemoteReject(
1331 "deletion prohibited".to_string(),
1332 ));
1333 }
1334 if receive_denies_current_branch_delete(format, command, config, request, remote_git_dir)? {
1335 return Ok(PushRefStatus::RemoteReject(
1336 "deletion of the current branch prohibited".to_string(),
1337 ));
1338 }
1339 }
1340
1341 if !request.dry_run && receive_denies_current_branch(format, command, config, remote_git_dir)? {
1342 return Ok(PushRefStatus::RemoteReject(
1343 "branch is currently checked out".to_string(),
1344 ));
1345 }
1346
1347 if command.name.starts_with("refs/heads/") && !command.new_id.is_null() {
1348 let object = local_db.read_object(&command.new_id)?;
1349 if object.object_type != ObjectType::Commit {
1350 return Ok(PushRefStatus::RemoteReject(
1351 "invalid new value provided".to_string(),
1352 ));
1353 }
1354 }
1355
1356 if let Some((_, expected)) = request
1360 .force_with_lease
1361 .iter()
1362 .find(|(dst, _)| *dst == command.name)
1363 {
1364 let actual = if command.old_id.is_null() {
1365 None
1366 } else {
1367 Some(command.old_id)
1368 };
1369 if *expected != actual {
1370 if plan.force {
1371 return Ok(PushRefStatus::Ok);
1372 }
1373 return Ok(PushRefStatus::RejectStale);
1374 }
1375 if request.force_if_includes
1376 && !command.old_id.is_null()
1377 && (command.new_id.is_null()
1378 || !is_fast_forward(local_db, format, &command.old_id, &command.new_id)?)
1379 && force_if_includes_rejects(
1380 local_db,
1381 format,
1382 request.git_dir,
1383 &command.name,
1384 &command.old_id,
1385 )?
1386 {
1387 if plan.force {
1388 return Ok(PushRefStatus::Ok);
1389 }
1390 return Ok(PushRefStatus::RejectRemoteUpdated);
1391 }
1392 return Ok(PushRefStatus::Ok);
1394 }
1395
1396 if command.name.starts_with("refs/heads/")
1397 && !command.old_id.is_null()
1398 && !command.new_id.is_null()
1399 && !is_fast_forward(local_db, format, &command.old_id, &command.new_id)?
1400 && receive_config_bool(
1401 config,
1402 request.receive_config_overrides,
1403 "denynonfastforwards",
1404 )
1405 .unwrap_or(false)
1406 {
1407 return Ok(PushRefStatus::RemoteReject(format!(
1408 "denying non-fast-forward {} (you should pull first)",
1409 command.name
1410 )));
1411 }
1412
1413 if !plan.force
1416 && command.name.starts_with("refs/tags/")
1417 && !command.old_id.is_null()
1418 && !command.new_id.is_null()
1419 {
1420 return Ok(PushRefStatus::RejectAlreadyExists);
1421 }
1422
1423 if !plan.force
1424 && command.name.starts_with("refs/heads/")
1425 && !command.old_id.is_null()
1426 && !command.new_id.is_null()
1427 && !is_fast_forward(local_db, format, &command.old_id, &command.new_id)?
1428 {
1429 return Ok(PushRefStatus::RejectNonFastForward);
1430 }
1431
1432 Ok(PushRefStatus::Ok)
1433}
1434
1435fn receive_ref_is_hidden(
1436 config: &GitConfig,
1437 overrides: &[(String, String)],
1438 refname: &str,
1439) -> bool {
1440 let mut hide_refs = Vec::new();
1441 hide_refs.extend(hidden_ref_values(config, "transfer", None));
1442 hide_refs.extend(hidden_ref_values(config, "receive", None));
1443 hide_refs.extend(
1444 overrides
1445 .iter()
1446 .filter(|(key, _)| key.eq_ignore_ascii_case("hiderefs"))
1447 .map(|(_, value)| trim_hidden_ref_pattern(value)),
1448 );
1449 ref_is_hidden_by_patterns(refname, &hide_refs)
1450}
1451
1452fn hidden_ref_values(config: &GitConfig, section: &str, subsection: Option<&str>) -> Vec<String> {
1453 config
1454 .get_all(section, subsection, "hiderefs")
1455 .into_iter()
1456 .flatten()
1457 .map(trim_hidden_ref_pattern)
1458 .collect()
1459}
1460
1461fn trim_hidden_ref_pattern(value: &str) -> String {
1462 value.trim_end_matches('/').to_string()
1463}
1464
1465fn ref_is_hidden_by_patterns(refname: &str, patterns: &[String]) -> bool {
1466 for pattern in patterns.iter().rev() {
1467 let mut pattern = pattern.as_str();
1468 let negated = pattern.strip_prefix('!').is_some();
1469 if negated {
1470 pattern = &pattern[1..];
1471 }
1472 if let Some(rest) = pattern.strip_prefix('^') {
1473 pattern = rest;
1474 }
1475 if hidden_ref_pattern_matches(refname, pattern) {
1476 return !negated;
1477 }
1478 }
1479 false
1480}
1481
1482fn hidden_ref_pattern_matches(refname: &str, pattern: &str) -> bool {
1483 refname
1484 .strip_prefix(pattern)
1485 .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
1486}
1487
1488fn lease_expectation_mismatch(request: &PushReportRequest<'_>, plan: &PlannedPushCommand) -> bool {
1489 let command = &plan.command;
1490 let actual = if command.old_id.is_null() {
1491 None
1492 } else {
1493 Some(command.old_id)
1494 };
1495 request
1496 .force_with_lease
1497 .iter()
1498 .find(|(dst, _)| *dst == command.name)
1499 .is_some_and(|(_, expected)| *expected != actual)
1500}
1501
1502fn force_if_includes_rejects(
1503 db: &FileObjectDatabase,
1504 format: ObjectFormat,
1505 git_dir: &Path,
1506 local_ref: &str,
1507 remote_old: &ObjectId,
1508) -> Result<bool> {
1509 let store = FileRefStore::new(git_dir, format);
1510 let mut candidates = Vec::new();
1511 match store.read_ref(local_ref)? {
1512 Some(RefTarget::Direct(oid)) => candidates.push(oid),
1513 Some(RefTarget::Symbolic(target)) => {
1514 if let Some(RefTarget::Direct(oid)) = store.read_ref(&target)? {
1515 candidates.push(oid);
1516 }
1517 }
1518 None => return Ok(false),
1519 }
1520 for entry in store.read_reflog(local_ref)? {
1521 if !entry.new_oid.is_null() {
1522 candidates.push(entry.new_oid);
1523 }
1524 }
1525 candidates.sort();
1526 candidates.dedup();
1527 for candidate in candidates {
1528 if candidate == *remote_old {
1529 return Ok(false);
1530 }
1531 if let Ok(ancestors) = ancestor_depths(db, format, &candidate)
1532 && ancestors.contains_key(remote_old)
1533 {
1534 return Ok(false);
1535 }
1536 }
1537 Ok(true)
1538}
1539
1540fn receive_config_bool(
1541 config: &GitConfig,
1542 overrides: &[(String, String)],
1543 key: &str,
1544) -> Option<bool> {
1545 overrides
1546 .iter()
1547 .rev()
1548 .find(|(candidate, _)| candidate.eq_ignore_ascii_case(key))
1549 .and_then(|(_, value)| sley_config::parse_config_bool(value))
1550 .or_else(|| config.get_bool("receive", None, key))
1551}
1552
1553fn receive_denies_current_branch(
1554 format: ObjectFormat,
1555 command: &ReceivePackCommand,
1556 config: &GitConfig,
1557 remote_git_dir: &Path,
1558) -> Result<bool> {
1559 if command.new_id.is_null() {
1560 return Ok(false);
1561 }
1562 if !command.name.starts_with("refs/heads/") {
1563 return Ok(false);
1564 }
1565 let deny = config
1566 .get("receive", None, "denycurrentbranch")
1567 .unwrap_or("refuse");
1568 let denies = matches!(
1569 deny.to_ascii_lowercase().as_str(),
1570 "true" | "yes" | "on" | "1" | "refuse"
1571 );
1572 if !denies {
1573 return Ok(false);
1574 }
1575 if sley_worktree::worktree_root_for_git_dir(remote_git_dir)?.is_none() {
1576 return Ok(false);
1577 }
1578 let store = FileRefStore::new(remote_git_dir, format);
1579 Ok(matches!(
1580 store.read_ref("HEAD")?,
1581 Some(RefTarget::Symbolic(target)) if target == command.name
1582 ))
1583}
1584
1585fn receive_targets_current_branch(
1586 format: ObjectFormat,
1587 command: &ReceivePackCommand,
1588 remote_git_dir: &Path,
1589) -> Result<bool> {
1590 if !command.name.starts_with("refs/heads/") {
1591 return Ok(false);
1592 }
1593 if sley_worktree::worktree_root_for_git_dir(remote_git_dir)?.is_none() {
1594 return Ok(false);
1595 }
1596 let store = FileRefStore::new(remote_git_dir, format);
1597 Ok(matches!(
1598 store.read_ref("HEAD")?,
1599 Some(RefTarget::Symbolic(target)) if target == command.name
1600 ))
1601}
1602
1603fn receive_denies_current_branch_delete(
1604 format: ObjectFormat,
1605 command: &ReceivePackCommand,
1606 config: &GitConfig,
1607 request: &PushReportRequest<'_>,
1608 remote_git_dir: &Path,
1609) -> Result<bool> {
1610 if !receive_targets_current_branch(format, command, remote_git_dir)? {
1611 return Ok(false);
1612 }
1613 let deny = request
1614 .receive_config_overrides
1615 .iter()
1616 .rev()
1617 .find(|(candidate, _)| candidate.eq_ignore_ascii_case("denydeletecurrent"))
1618 .map(|(_, value)| value.as_str())
1619 .or_else(|| config.get("receive", None, "denydeletecurrent"))
1620 .unwrap_or("refuse");
1621 Ok(!matches!(
1622 deny.to_ascii_lowercase().as_str(),
1623 "ignore" | "warn" | "false" | "no" | "off" | "0"
1624 ))
1625}
1626
1627pub(crate) fn is_fast_forward(
1630 db: &FileObjectDatabase,
1631 format: ObjectFormat,
1632 old: &ObjectId,
1633 new: &ObjectId,
1634) -> Result<bool> {
1635 let ancestors = ancestor_depths(db, format, new)?;
1636 Ok(ancestors.contains_key(old))
1637}
1638
1639#[cfg(feature = "http")]
1642fn advertised_receive_pack_features(
1643 advertisements: &[RefAdvertisement],
1644) -> Result<ReceivePackFeatures> {
1645 advertisements
1646 .first()
1647 .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
1648 .transpose()
1649 .map(Option::unwrap_or_default)
1650}
1651
1652#[cfg(feature = "http")]
1655fn verify_remote_object_format(features: &ReceivePackFeatures, format: ObjectFormat) -> Result<()> {
1656 if let Some(remote_format) = features.object_format {
1657 if remote_format != format {
1658 return Err(GitError::InvalidObjectId(format!(
1659 "remote repository uses {}, local repository uses {}",
1660 remote_format.name(),
1661 format.name()
1662 )));
1663 }
1664 } else if format != ObjectFormat::Sha1 {
1665 return Err(GitError::InvalidObjectId(format!(
1666 "remote repository did not advertise object-format for {} push",
1667 format.name()
1668 )));
1669 }
1670 Ok(())
1671}
1672
1673#[cfg(feature = "http")]
1678fn receive_pack_push_options(
1679 features: &ReceivePackFeatures,
1680 format: ObjectFormat,
1681 quiet: bool,
1682) -> ReceivePackPushRequestOptions {
1683 ReceivePackPushRequestOptions {
1684 report_status: features.report_status,
1685 ofs_delta: features.ofs_delta,
1686 quiet: quiet && features.quiet,
1687 object_format: features
1688 .object_format
1689 .filter(|_| format != ObjectFormat::Sha1),
1690 ..ReceivePackPushRequestOptions::default()
1691 }
1692}
1693
1694pub(crate) fn plan_push_command_forces(
1699 format: ObjectFormat,
1700 local_refs: &[PushSourceRef],
1701 remote_refs: &[RefAdvertisement],
1702 refspecs: &[String],
1703 force: bool,
1704) -> Result<Vec<(ReceivePackCommand, bool)>> {
1705 let parsed_refspecs = refspecs
1706 .iter()
1707 .map(|refspec| {
1708 let normalized = normalize_push_refspec_for_sources(refspec, local_refs, remote_refs)?;
1709 parse_refspec(&normalized)
1710 })
1711 .collect::<Result<Vec<_>>>()?;
1712 let mut command_forces = Vec::new();
1713 for refspec in &parsed_refspecs {
1714 for command in plan_push_commands(
1715 format,
1716 local_refs,
1717 remote_refs,
1718 std::slice::from_ref(refspec),
1719 )? {
1720 command_forces.push((command, force || refspec.force));
1721 }
1722 }
1723 Ok(command_forces)
1724}
1725
1726struct PlannedPushCommand {
1729 command: ReceivePackCommand,
1730 force: bool,
1731 source: Option<String>,
1732}
1733
1734fn plan_push_command_sources(
1740 format: ObjectFormat,
1741 local_refs: &[PushSourceRef],
1742 remote_refs: &[RefAdvertisement],
1743 refspecs: &[String],
1744 force: bool,
1745) -> Result<Vec<PlannedPushCommand>> {
1746 let mut planned = Vec::new();
1747 for refspec in refspecs {
1748 let normalized = normalize_push_refspec_for_sources(refspec, local_refs, remote_refs)?;
1749 let parsed = parse_refspec(&normalized)?;
1750 let commands = plan_push_commands(
1751 format,
1752 local_refs,
1753 remote_refs,
1754 std::slice::from_ref(&parsed),
1755 )?;
1756 for command in commands {
1757 let source = push_command_source_name(&parsed, &command);
1758 planned.push(PlannedPushCommand {
1759 command,
1760 force: force || parsed.force,
1761 source,
1762 });
1763 }
1764 }
1765 Ok(planned)
1766}
1767
1768fn push_command_source_name(refspec: &RefSpec, command: &ReceivePackCommand) -> Option<String> {
1773 let src = refspec.src.as_deref()?;
1774 if !refspec.pattern {
1775 return Some(src.to_string());
1776 }
1777 let (src_prefix, src_suffix) = src.split_once('*')?;
1778 let dst = refspec.dst.as_deref()?;
1779 let (dst_prefix, dst_suffix) = dst.split_once('*')?;
1780 let stem = command
1781 .name
1782 .strip_prefix(dst_prefix)
1783 .and_then(|rest| rest.strip_suffix(dst_suffix))?;
1784 Some(format!("{src_prefix}{stem}{src_suffix}"))
1785}
1786
1787pub(crate) fn add_revision_push_sources(
1788 git_dir: &Path,
1789 format: ObjectFormat,
1790 refspecs: &[String],
1791 local_refs: &mut Vec<PushSourceRef>,
1792) {
1793 for refspec in refspecs {
1794 let refspec = refspec.strip_prefix('+').unwrap_or(refspec);
1795 let src = refspec.split_once(':').map_or(refspec, |(src, _)| src);
1796 if src.is_empty() || src == "HEAD" {
1797 continue;
1798 }
1799 if src.starts_with("refs/") && local_refs.iter().any(|reference| reference.name == src) {
1800 continue;
1801 }
1802 if local_refs.iter().any(|reference| {
1803 reference.name == src
1804 || reference.name == format!("refs/heads/{src}")
1805 || reference.name == format!("refs/tags/{src}")
1806 }) {
1807 continue;
1808 }
1809 if let Ok(oid) = sley_rev::resolve_revision(git_dir, format, src)
1810 && !local_refs.iter().any(|reference| reference.name == src)
1811 {
1812 local_refs.push(PushSourceRef {
1813 name: src.to_string(),
1814 oid,
1815 });
1816 }
1817 }
1818}
1819
1820fn normalize_push_refspec_for_sources(
1821 refspec: &str,
1822 local_refs: &[PushSourceRef],
1823 remote_refs: &[RefAdvertisement],
1824) -> Result<String> {
1825 let (force, refspec) = refspec
1826 .strip_prefix('+')
1827 .map_or((false, refspec), |refspec| (true, refspec));
1828 let normalized = if let Some((src, dst)) = refspec.split_once(':') {
1829 let (src, src_kind) = normalize_push_source_refname(src, local_refs);
1830 let dst = if src.is_empty() {
1831 normalize_push_delete_destination_refname(dst, remote_refs)?
1832 } else {
1833 normalize_push_destination_refname(dst, src_kind, remote_refs)?
1834 };
1835 if !src.is_empty() && !dst.contains('*') && push_destination_is_onelevel_under_refs(&dst) {
1836 return Err(GitError::Command(format!(
1837 "destination refspec {dst} is not a valid ref"
1838 )));
1839 }
1840 format!("{src}:{dst}")
1841 } else {
1842 let (name, _) = normalize_push_source_refname(refspec, local_refs);
1843 let dst = match count_refspec_match_dst(&name, remote_refs) {
1850 DstMatch::Unique(matched) => matched.to_string(),
1851 DstMatch::None => name.clone(),
1852 DstMatch::Ambiguous => {
1853 return Err(GitError::Command(format!(
1854 "dst refspec {name} matches more than one"
1855 )));
1856 }
1857 };
1858 format!("{name}:{dst}")
1859 };
1860 Ok(if force {
1861 format!("+{normalized}")
1862 } else {
1863 normalized
1864 })
1865}
1866
1867fn refname_match_rank(abbrev: &str, full_name: &str) -> Option<usize> {
1871 const RULES: [&str; 6] = [
1872 "{}",
1873 "refs/{}",
1874 "refs/tags/{}",
1875 "refs/heads/{}",
1876 "refs/remotes/{}",
1877 "refs/remotes/{}/HEAD",
1878 ];
1879 for (idx, rule) in RULES.iter().enumerate() {
1880 let (prefix, suffix) = rule.split_once("{}").unwrap_or((rule, ""));
1881 if full_name == format!("{prefix}{abbrev}{suffix}") {
1882 return Some(RULES.len() - idx);
1883 }
1884 }
1885 None
1886}
1887
1888enum DstMatch<'a> {
1890 Unique(&'a str),
1892 None,
1894 Ambiguous,
1896}
1897
1898fn count_refspec_match_dst<'a>(pattern: &str, remote_refs: &'a [RefAdvertisement]) -> DstMatch<'a> {
1905 let patlen = pattern.len();
1906 let mut strong: Option<&str> = None;
1907 let mut strong_count = 0usize;
1908 let mut weak: Option<&str> = None;
1909 let mut weak_count = 0usize;
1910 for advert in remote_refs {
1911 let name = advert.name.as_str();
1912 if refname_match_rank(pattern, name).is_none() {
1913 continue;
1914 }
1915 let namelen = name.len();
1916 let is_weak = namelen != patlen
1917 && patlen + 5 != namelen
1918 && !name.starts_with("refs/heads/")
1919 && !name.starts_with("refs/tags/");
1920 if is_weak {
1921 weak = Some(name);
1922 weak_count += 1;
1923 } else {
1924 strong = Some(name);
1925 strong_count += 1;
1926 }
1927 }
1928 match (strong_count, weak_count, strong, weak) {
1929 (1, _, Some(matched), _) => DstMatch::Unique(matched),
1930 (0, 1, _, Some(matched)) => DstMatch::Unique(matched),
1931 (0, 0, _, _) => DstMatch::None,
1932 _ => DstMatch::Ambiguous,
1933 }
1934}
1935
1936#[derive(Clone, Copy)]
1937enum PushSourceKind {
1938 Branch,
1939 Tag,
1940 Other,
1944 Unqualifiable,
1948}
1949
1950fn normalize_push_source_refname(
1951 name: &str,
1952 local_refs: &[PushSourceRef],
1953) -> (String, PushSourceKind) {
1954 if name.is_empty() || name == "HEAD" || name == "@" || name.starts_with("refs/") {
1957 return (name.to_string(), PushSourceKind::Other);
1958 }
1959 let branch = format!("refs/heads/{name}");
1960 let tag = format!("refs/tags/{name}");
1961 let has_branch = local_refs.iter().any(|reference| reference.name == branch);
1962 let has_tag = local_refs.iter().any(|reference| reference.name == tag);
1963 if has_tag && !has_branch {
1964 (tag, PushSourceKind::Tag)
1965 } else if has_branch {
1966 (branch, PushSourceKind::Branch)
1967 } else if local_refs.iter().any(|reference| reference.name == name) {
1968 (name.to_string(), PushSourceKind::Unqualifiable)
1972 } else {
1973 (branch, PushSourceKind::Branch)
1974 }
1975}
1976
1977fn normalize_push_delete_destination_refname(
1978 name: &str,
1979 remote_refs: &[RefAdvertisement],
1980) -> Result<String> {
1981 if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
1982 return Ok(name.to_string());
1983 }
1984 match count_refspec_match_dst(name, remote_refs) {
1985 DstMatch::Unique(matched) => Ok(matched.to_string()),
1986 DstMatch::Ambiguous => Err(GitError::Command(format!(
1987 "dst refspec {name} matches more than one"
1988 ))),
1989 DstMatch::None => Err(GitError::reference_not_found(format!("remote ref {name}"))),
1990 }
1991}
1992
1993fn normalize_push_destination_refname(
1994 name: &str,
1995 src_kind: PushSourceKind,
1996 remote_refs: &[RefAdvertisement],
1997) -> Result<String> {
1998 if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
1999 return Ok(name.to_string());
2000 }
2001 match count_refspec_match_dst(name, remote_refs) {
2007 DstMatch::Unique(matched) => Ok(matched.to_string()),
2008 DstMatch::Ambiguous => Err(GitError::Command(format!(
2009 "dst refspec {name} matches more than one"
2010 ))),
2011 DstMatch::None => match src_kind {
2012 PushSourceKind::Tag => Ok(format!("refs/tags/{name}")),
2013 PushSourceKind::Branch | PushSourceKind::Other => Ok(format!("refs/heads/{name}")),
2014 PushSourceKind::Unqualifiable => Err(GitError::Command(format!(
2018 "the destination you provided is not a full refname (i.e., starting with \"refs/\"); unable to guess the destination for {name}"
2019 ))),
2020 },
2021 }
2022}
2023
2024fn push_destination_is_onelevel_under_refs(name: &str) -> bool {
2025 name.strip_prefix("refs/")
2026 .is_some_and(|rest| !rest.contains('/'))
2027}
2028
2029fn commands_from_forces(command_forces: &[(ReceivePackCommand, bool)]) -> Vec<ReceivePackCommand> {
2031 command_forces
2032 .iter()
2033 .map(|(command, _)| command.clone())
2034 .collect()
2035}
2036
2037fn receive_pack_commands_from_action_plan(
2038 format: ObjectFormat,
2039 plan: &PushActionPlan,
2040) -> Result<Vec<ReceivePackCommand>> {
2041 let zero = ObjectId::null(format);
2042 for oid in &plan.pack_objects {
2043 if oid.format() != format {
2044 return Err(GitError::InvalidObjectId(format!(
2045 "push pack object {oid} has {} object id for {} repository",
2046 oid.format().name(),
2047 format.name()
2048 )));
2049 }
2050 }
2051 plan.commands
2052 .iter()
2053 .map(|command| {
2054 let old_id = command.expected_old.unwrap_or(zero);
2055 let new_id = command.src.unwrap_or(zero);
2056 if old_id.format() != format {
2057 return Err(GitError::InvalidObjectId(format!(
2058 "push command {} expected old has {} object id for {} repository",
2059 command.dst,
2060 old_id.format().name(),
2061 format.name()
2062 )));
2063 }
2064 if new_id.format() != format {
2065 return Err(GitError::InvalidObjectId(format!(
2066 "push command {} new id has {} object id for {} repository",
2067 command.dst,
2068 new_id.format().name(),
2069 format.name()
2070 )));
2071 }
2072 Ok(ReceivePackCommand {
2073 old_id,
2074 new_id,
2075 name: command.dst.clone(),
2076 })
2077 })
2078 .collect()
2079}
2080
2081pub fn validate_receive_pack_report(report: &ReceivePackReportStatus) -> Result<()> {
2084 if let ReceivePackUnpackStatus::Error(message) = &report.unpack {
2085 return Err(GitError::Command(format!(
2086 "failed to push some refs: unpack failed: {message}"
2087 )));
2088 }
2089 for status in &report.commands {
2090 if let ReceivePackCommandStatus::Ng { name, message } = status {
2091 return Err(GitError::Command(format!(
2092 "failed to push {name}: {message}"
2093 )));
2094 }
2095 }
2096 Ok(())
2097}
2098
2099pub fn local_push_source_refs(
2103 store: &FileRefStore,
2104 format: ObjectFormat,
2105) -> Result<Vec<PushSourceRef>> {
2106 let mut refs = Vec::new();
2107 for reference in store.list_refs()? {
2108 let Some((oid, _)) = resolve_for_each_ref_target(store, &reference)? else {
2109 continue;
2110 };
2111 if oid.format() != format {
2112 return Err(GitError::InvalidObjectId(format!(
2113 "local ref {} has {} object id for {} repository",
2114 reference.name,
2115 oid.format().name(),
2116 format.name()
2117 )));
2118 }
2119 refs.push(PushSourceRef {
2120 name: reference.name.clone(),
2121 oid,
2122 });
2123 if let Some(short) = reference.name.strip_prefix("refs/heads/") {
2124 refs.push(PushSourceRef {
2125 name: short.to_string(),
2126 oid,
2127 });
2128 }
2129 if let Some(short) = reference.name.strip_prefix("refs/tags/") {
2130 refs.push(PushSourceRef {
2131 name: short.to_string(),
2132 oid,
2133 });
2134 }
2135 }
2136 if let Some(target) = store.read_ref("HEAD")? {
2137 let head = Ref {
2138 name: "HEAD".to_string(),
2139 target,
2140 };
2141 if let Some((oid, _)) = resolve_for_each_ref_target(store, &head)?
2142 && oid.format() == format
2143 {
2144 refs.push(PushSourceRef {
2145 name: "HEAD".to_string(),
2146 oid,
2147 });
2148 }
2149 }
2150 Ok(refs)
2151}
2152
2153pub fn normalize_push_refspec(refspec: &str) -> String {
2157 let (force, refspec) = refspec
2158 .strip_prefix('+')
2159 .map_or((false, refspec), |refspec| (true, refspec));
2160 let normalized = if let Some((src, dst)) = refspec.split_once(':') {
2161 let src = normalize_push_refname(src);
2162 let dst = normalize_push_refname(dst);
2163 format!("{src}:{dst}")
2164 } else {
2165 let name = normalize_push_refname(refspec);
2166 format!("{name}:{name}")
2167 };
2168 if force {
2169 format!("+{normalized}")
2170 } else {
2171 normalized
2172 }
2173}
2174
2175pub fn normalize_push_refname(name: &str) -> String {
2178 if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
2179 name.to_string()
2180 } else {
2181 format!("refs/heads/{name}")
2182 }
2183}
2184
2185pub fn reject_non_fast_forward_pushes(
2189 local_db: &FileObjectDatabase,
2190 format: ObjectFormat,
2191 command_forces: &[(ReceivePackCommand, bool)],
2192) -> Result<()> {
2193 for (command, force) in command_forces {
2194 if *force
2195 || !command.name.starts_with("refs/heads/")
2196 || command.old_id.is_null()
2197 || command.new_id.is_null()
2198 {
2199 continue;
2200 }
2201 let ancestors = ancestor_depths(local_db, format, &command.new_id)?;
2202 if !ancestors.contains_key(&command.old_id) {
2203 let short = command.name.trim_start_matches("refs/heads/");
2204 return Err(GitError::Command(format!(
2205 "failed to push some refs: non-fast-forward update to {short}"
2206 )));
2207 }
2208 }
2209 Ok(())
2210}
2211
2212fn ancestor_depths(
2216 db: &FileObjectDatabase,
2217 format: ObjectFormat,
2218 start: &ObjectId,
2219) -> Result<HashMap<ObjectId, usize>> {
2220 let mut depths = HashMap::new();
2221 let mut pending = std::collections::VecDeque::from([(start.clone(), 0usize)]);
2222 while let Some((oid, depth)) = pending.pop_front() {
2223 if depths.get(&oid).is_some_and(|existing| *existing <= depth) {
2224 continue;
2225 }
2226 depths.insert(oid, depth);
2227 let object = db.read_object(&oid)?;
2228 if object.object_type != ObjectType::Commit {
2229 return Err(GitError::InvalidObject(format!(
2230 "expected commit {oid}, found {}",
2231 object.object_type.as_str()
2232 )));
2233 }
2234 let commit = Commit::parse_ref(format, &object.body)?;
2235 for parent in commit.parents {
2236 pending.push_back((parent, depth + 1));
2237 }
2238 }
2239 Ok(depths)
2240}
2241
2242fn resolve_for_each_ref_target(
2245 store: &FileRefStore,
2246 reference: &Ref,
2247) -> Result<Option<(ObjectId, Option<String>)>> {
2248 let mut target = reference.target.clone();
2249 let mut symref = None;
2250 for _ in 0..5 {
2251 match target {
2252 RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
2253 RefTarget::Symbolic(name) => {
2254 symref.get_or_insert_with(|| name.clone());
2255 let Some(next) = store.read_ref(&name)? else {
2256 return Ok(None);
2257 };
2258 target = next;
2259 }
2260 }
2261 }
2262 Ok(None)
2263}
2264
2265#[cfg(test)]
2266mod tests {
2267 use super::*;
2268 use std::fs;
2269 use std::sync::atomic::{AtomicU64, Ordering};
2270
2271 use sley_formats::RepositoryLayout;
2272 use sley_object::{Commit, EncodedObject, ObjectType, Tree};
2273 use sley_odb::{FileObjectDatabase, ObjectWriter};
2274 use sley_protocol::{ReceivePackCommandStatus, ReceivePackUnpackStatus};
2275 use sley_refs::{RefTarget, RefUpdate};
2276
2277 use crate::{NoCredentials, SilentProgress};
2278
2279 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
2280
2281 fn temp_repo(name: &str) -> PathBuf {
2282 let dir = std::env::temp_dir().join(format!(
2283 "sley-remote-push-{name}-{}-{}",
2284 std::process::id(),
2285 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
2286 ));
2287 let _ = fs::remove_dir_all(&dir);
2288 RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
2289 .expect("test repository should initialize");
2290 dir.join(".git")
2291 }
2292
2293 fn write_commit(git_dir: &Path, parents: Vec<ObjectId>, message: &str) -> ObjectId {
2294 let format = ObjectFormat::Sha1;
2295 let db = FileObjectDatabase::from_git_dir(git_dir, format);
2296 let tree = db
2297 .write_object(EncodedObject::new(
2298 ObjectType::Tree,
2299 Tree { entries: vec![] }.write(),
2300 ))
2301 .expect("tree should write");
2302 let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
2303 db.write_object(EncodedObject::new(
2304 ObjectType::Commit,
2305 Commit {
2306 tree,
2307 parents,
2308 author: identity.clone(),
2309 committer: identity,
2310 encoding: None,
2311 message: format!("{message}\n").into_bytes(),
2312 }
2313 .write(),
2314 ))
2315 .expect("commit should write")
2316 }
2317
2318 fn set_ref(git_dir: &Path, name: &str, target: RefTarget) {
2319 let store = FileRefStore::new(git_dir, ObjectFormat::Sha1);
2320 let mut tx = store.transaction();
2321 tx.update(RefUpdate {
2322 name: name.to_string(),
2323 expected: None,
2324 new: target,
2325 reflog: None,
2326 });
2327 tx.commit().expect("ref should update");
2328 }
2329
2330 fn default_options() -> PushOptions {
2331 PushOptions {
2332 quiet: true,
2333 force: false,
2334 }
2335 }
2336
2337 #[derive(Default)]
2341 struct RecordingClient {
2342 last: std::sync::Mutex<Option<(&'static str, Vec<u8>)>>,
2343 }
2344
2345 impl RecordingClient {
2346 fn take(&self) -> (&'static str, Vec<u8>) {
2347 self.last
2348 .lock()
2349 .expect("lock")
2350 .take()
2351 .expect("a send was recorded")
2352 }
2353
2354 fn ok_response() -> Result<HttpResponse> {
2355 Ok(HttpResponse {
2356 status: 200,
2357 content_type: None,
2358 body: Box::new(std::io::empty()),
2359 })
2360 }
2361 }
2362
2363 impl HttpClient for RecordingClient {
2364 fn get(&self, _url: &str, _headers: &[(&str, &str)]) -> Result<HttpResponse> {
2365 Self::ok_response()
2366 }
2367
2368 fn post(
2369 &self,
2370 _url: &str,
2371 _content_type: &str,
2372 _headers: &[(&str, &str)],
2373 body: &[u8],
2374 ) -> Result<HttpResponse> {
2375 *self.last.lock().expect("lock") = Some(("post", body.to_vec()));
2376 Self::ok_response()
2377 }
2378
2379 fn post_reader(
2380 &self,
2381 _url: &str,
2382 _content_type: &str,
2383 _headers: &[(&str, &str)],
2384 body: &mut dyn Read,
2385 ) -> Result<HttpResponse> {
2386 let mut buffered = Vec::new();
2387 body.read_to_end(&mut buffered)
2388 .map_err(|err| GitError::Io(err.to_string()))?;
2389 *self.last.lock().expect("lock") = Some(("post_reader", buffered));
2390 Self::ok_response()
2391 }
2392 }
2393
2394 fn receive_pack_request<'a>(
2395 db: &'a FileObjectDatabase,
2396 commands: &'a [ReceivePackCommand],
2397 advertisements: &'a [RefAdvertisement],
2398 features: &'a ReceivePackFeatures,
2399 ) -> PushPackRequest<'a> {
2400 PushPackRequest {
2401 local_db: db,
2402 format: ObjectFormat::Sha1,
2403 commands,
2404 pack_objects: &[],
2405 remote_advertisements: advertisements,
2406 features,
2407 options: ReceivePackPushRequestOptions {
2408 report_status: true,
2409 ofs_delta: true,
2410 ..ReceivePackPushRequestOptions::default()
2411 },
2412 thin: false,
2413 }
2414 }
2415
2416 #[test]
2417 fn send_receive_pack_body_gates_on_post_buffer_and_preserves_bytes() {
2418 let git_dir = temp_repo("send-receive-pack-gate");
2419 let commit = write_commit(&git_dir, vec![], "streamed http push");
2420 let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
2421 let commands = [ReceivePackCommand {
2422 old_id: ObjectId::null(ObjectFormat::Sha1),
2423 new_id: commit,
2424 name: "refs/heads/main".into(),
2425 }];
2426 let features = ReceivePackFeatures {
2427 report_status: true,
2428 ofs_delta: true,
2429 ..ReceivePackFeatures::default()
2430 };
2431 let req = receive_pack_request(&db, &commands, &[], &features);
2432
2433 let mut canonical = Vec::new();
2435 write_receive_pack_body(&req, &mut canonical).expect("canonical body");
2436 assert!(canonical.len() > 1, "body should be non-trivial");
2437
2438 let buffered_client = RecordingClient::default();
2440 send_receive_pack_body(
2441 &buffered_client,
2442 "http://h/git-receive-pack",
2443 "ct",
2444 &[],
2445 &req,
2446 usize::MAX,
2447 )
2448 .expect("buffered send");
2449 let (method, body) = buffered_client.take();
2450 assert_eq!(method, "post");
2451 assert_eq!(body, canonical);
2452
2453 let streamed_client = RecordingClient::default();
2457 send_receive_pack_body(
2458 &streamed_client,
2459 "http://h/git-receive-pack",
2460 "ct",
2461 &[],
2462 &req,
2463 8,
2464 )
2465 .expect("streamed send");
2466 let (method, body) = streamed_client.take();
2467 assert_eq!(method, "post_reader");
2468 assert_eq!(body, canonical);
2469
2470 let _ = fs::remove_dir_all(git_dir.parent().unwrap_or(&git_dir));
2471 }
2472
2473 #[test]
2474 fn parse_post_buffer_reads_git_size_values() {
2475 assert_eq!(parse_post_buffer("1048576"), Some(1 << 20));
2476 assert_eq!(parse_post_buffer("512k"), Some(512 * 1024));
2477 assert_eq!(parse_post_buffer("1M"), Some(1024 * 1024));
2478 assert_eq!(parse_post_buffer("2g"), Some(2 * 1024 * 1024 * 1024));
2479 assert_eq!(parse_post_buffer(" 64k "), Some(64 * 1024));
2480 assert_eq!(parse_post_buffer("garbage"), None);
2481 assert_eq!(parse_post_buffer(""), None);
2482 }
2483
2484 #[test]
2485 fn push_action_plan_infers_pack_roots_from_non_delete_commands() {
2486 let repo = temp_repo("action-plan-infer-roots");
2487 let first = write_commit(&repo, Vec::new(), "first");
2488 let second = write_commit(&repo, vec![first], "second");
2489
2490 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
2491 vec![
2492 PushCommand {
2493 src: Some(first),
2494 dst: "refs/heads/main".into(),
2495 expected_old: None,
2496 force: false,
2497 },
2498 PushCommand {
2499 src: Some(second),
2500 dst: "refs/heads/topic".into(),
2501 expected_old: Some(first),
2502 force: true,
2503 },
2504 ],
2505 default_options(),
2506 );
2507
2508 assert_eq!(plan.pack_objects, vec![first, second]);
2509 assert!(!plan.commands[0].force);
2510 assert!(plan.commands[1].force);
2511 }
2512
2513 #[test]
2514 fn push_action_plan_inferred_pack_roots_exclude_deletes() {
2515 let repo = temp_repo("action-plan-delete-roots");
2516 let old = write_commit(&repo, Vec::new(), "old");
2517 let new = write_commit(&repo, vec![old], "new");
2518
2519 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
2520 vec![
2521 PushCommand {
2522 src: None,
2523 dst: "refs/heads/remove".into(),
2524 expected_old: Some(old),
2525 force: false,
2526 },
2527 PushCommand {
2528 src: Some(new),
2529 dst: "refs/heads/keep".into(),
2530 expected_old: Some(old),
2531 force: false,
2532 },
2533 ],
2534 default_options(),
2535 );
2536
2537 assert_eq!(plan.pack_objects, vec![new]);
2538 }
2539
2540 #[test]
2541 fn push_action_plan_inferred_pack_roots_dedupe_first_seen_order() {
2542 let repo = temp_repo("action-plan-dedupe-roots");
2543 let first = write_commit(&repo, Vec::new(), "first");
2544 let second = write_commit(&repo, Vec::new(), "second");
2545
2546 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
2547 vec![
2548 PushCommand {
2549 src: Some(second),
2550 dst: "refs/heads/second".into(),
2551 expected_old: None,
2552 force: false,
2553 },
2554 PushCommand {
2555 src: Some(first),
2556 dst: "refs/heads/first".into(),
2557 expected_old: None,
2558 force: false,
2559 },
2560 PushCommand {
2561 src: Some(second),
2562 dst: "refs/tags/second".into(),
2563 expected_old: None,
2564 force: false,
2565 },
2566 PushCommand {
2567 src: Some(first),
2568 dst: "refs/tags/first".into(),
2569 expected_old: None,
2570 force: false,
2571 },
2572 ],
2573 default_options(),
2574 );
2575
2576 assert_eq!(plan.pack_objects, vec![second, first]);
2577 }
2578
2579 fn push_local_actions(
2580 local: &Path,
2581 remote: &Path,
2582 plan: &PushActionPlan,
2583 ) -> Result<PushOutcome> {
2584 let destination = PushDestination::Local {
2585 git_dir: remote.to_path_buf(),
2586 common_git_dir: remote.to_path_buf(),
2587 };
2588 let config = GitConfig::default();
2589 let mut credentials = NoCredentials;
2590 let mut progress = SilentProgress;
2591 push_actions(
2592 PushActionRequest {
2593 git_dir: local,
2594 common_git_dir: local,
2595 format: ObjectFormat::Sha1,
2596 config: &config,
2597 remote: "origin",
2598 destination: &destination,
2599 plan,
2600 },
2601 PushServices {
2602 credentials: &mut credentials,
2603 progress: &mut progress,
2604 },
2605 )
2606 }
2607
2608 #[test]
2609 fn local_push_returns_success_report_status_and_updates_ref() {
2610 let local = temp_repo("local-success");
2611 let remote = temp_repo("remote-success");
2612 let base = write_commit(&local, Vec::new(), "base");
2613 let tip = write_commit(&local, vec![base], "tip");
2614 set_ref(&local, "refs/heads/main", RefTarget::Direct(tip));
2615 set_ref(
2616 &local,
2617 "HEAD",
2618 RefTarget::Symbolic("refs/heads/main".into()),
2619 );
2620 let destination = PushDestination::Local {
2621 git_dir: remote.clone(),
2622 common_git_dir: remote.clone(),
2623 };
2624 let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
2625 let options = default_options();
2626 let request = PushRequest {
2627 git_dir: &local,
2628 common_git_dir: &local,
2629 format: ObjectFormat::Sha1,
2630 config: &GitConfig::default(),
2631 remote: "origin",
2632 destination: &destination,
2633 refspecs: &refspecs,
2634 options: &options,
2635 };
2636 let mut credentials = NoCredentials;
2637 let mut progress = SilentProgress;
2638
2639 let outcome = push(
2640 request,
2641 PushServices {
2642 credentials: &mut credentials,
2643 progress: &mut progress,
2644 },
2645 )
2646 .expect("push should succeed");
2647
2648 assert_eq!(outcome.commands.len(), 1);
2649 let report = outcome.report.expect("local receive-pack reports status");
2650 assert!(matches!(report.unpack, ReceivePackUnpackStatus::Ok));
2651 assert!(matches!(
2652 report.commands.as_slice(),
2653 [ReceivePackCommandStatus::Ok { name }] if name == "refs/heads/main"
2654 ));
2655 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2656 assert_eq!(
2657 remote_refs
2658 .read_ref("refs/heads/main")
2659 .expect("remote ref should read"),
2660 Some(RefTarget::Direct(tip))
2661 );
2662 }
2663
2664 #[test]
2665 fn local_push_actions_preserves_exact_old_new_update() {
2666 let local = temp_repo("actions-update-local");
2667 let remote = temp_repo("actions-update-remote");
2668 let base = write_commit(&local, Vec::new(), "base");
2669 let remote_base = write_commit(&remote, Vec::new(), "base");
2670 assert_eq!(remote_base, base);
2671 let tip = write_commit(&local, vec![base], "tip");
2672 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2673 let plan = PushActionPlan::from_actions(
2674 vec![PushAction::Update {
2675 dst: "refs/heads/main".into(),
2676 old: base,
2677 new: tip,
2678 }],
2679 default_options(),
2680 );
2681
2682 let outcome = push_local_actions(&local, &remote, &plan).expect("push actions");
2683
2684 assert_eq!(outcome.commands.len(), 1);
2685 assert_eq!(outcome.commands[0].old_id, base);
2686 assert_eq!(outcome.commands[0].new_id, tip);
2687 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2688 assert_eq!(
2689 remote_refs
2690 .read_ref("refs/heads/main")
2691 .expect("remote ref should read"),
2692 Some(RefTarget::Direct(tip))
2693 );
2694 }
2695
2696 #[test]
2697 fn local_push_actions_honors_per_command_force() {
2698 let local = temp_repo("actions-command-force-local");
2699 let remote = temp_repo("actions-command-force-remote");
2700 let base = write_commit(&local, Vec::new(), "base");
2701 let remote_base = write_commit(&remote, Vec::new(), "base");
2702 assert_eq!(remote_base, base);
2703 let unrelated = write_commit(&local, Vec::new(), "unrelated");
2704 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2705
2706 let unforced = PushActionPlan::from_commands(
2707 vec![PushCommand {
2708 src: Some(unrelated),
2709 dst: "refs/heads/main".into(),
2710 expected_old: Some(base),
2711 force: false,
2712 }],
2713 default_options(),
2714 );
2715 let err = push_local_actions(&local, &remote, &unforced)
2716 .expect_err("non-fast-forward should reject without command force");
2717 assert!(err.to_string().contains("non-fast-forward"));
2718
2719 let forced = PushActionPlan::from_commands(
2720 vec![PushCommand {
2721 src: Some(unrelated),
2722 dst: "refs/heads/main".into(),
2723 expected_old: Some(base),
2724 force: true,
2725 }],
2726 default_options(),
2727 );
2728 let outcome = push_local_actions(&local, &remote, &forced).expect("command force pushes");
2729
2730 assert_eq!(outcome.commands.len(), 1);
2731 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2732 assert_eq!(
2733 remote_refs
2734 .read_ref("refs/heads/main")
2735 .expect("remote ref should read"),
2736 Some(RefTarget::Direct(unrelated))
2737 );
2738 }
2739
2740 #[test]
2741 fn local_push_actions_command_force_is_precise_for_non_ff_validation() {
2742 let local = temp_repo("actions-command-force-precise-local");
2743 let remote = temp_repo("actions-command-force-precise-remote");
2744 let base = write_commit(&local, Vec::new(), "base");
2745 let remote_base = write_commit(&remote, Vec::new(), "base");
2746 assert_eq!(remote_base, base);
2747 let forced_unrelated = write_commit(&local, Vec::new(), "forced unrelated");
2748 let unforced_unrelated = write_commit(&local, Vec::new(), "unforced unrelated");
2749 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2750 set_ref(&remote, "refs/heads/topic", RefTarget::Direct(base));
2751 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
2752 vec![
2753 PushCommand {
2754 src: Some(forced_unrelated),
2755 dst: "refs/heads/main".into(),
2756 expected_old: Some(base),
2757 force: true,
2758 },
2759 PushCommand {
2760 src: Some(unforced_unrelated),
2761 dst: "refs/heads/topic".into(),
2762 expected_old: Some(base),
2763 force: false,
2764 },
2765 ],
2766 default_options(),
2767 );
2768
2769 let err = push_local_actions(&local, &remote, &plan)
2770 .expect_err("only the forced command should bypass non-fast-forward validation");
2771
2772 assert!(err.to_string().contains("non-fast-forward update to topic"));
2773 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2774 assert_eq!(
2775 remote_refs
2776 .read_ref("refs/heads/main")
2777 .expect("remote ref should read"),
2778 Some(RefTarget::Direct(base))
2779 );
2780 assert_eq!(
2781 remote_refs
2782 .read_ref("refs/heads/topic")
2783 .expect("remote ref should read"),
2784 Some(RefTarget::Direct(base))
2785 );
2786 }
2787
2788 #[test]
2789 fn local_push_actions_stale_update_old_rejects_without_mutating() {
2790 let local = temp_repo("actions-stale-local");
2791 let remote = temp_repo("actions-stale-remote");
2792 let base = write_commit(&local, Vec::new(), "base");
2793 let remote_base = write_commit(&remote, Vec::new(), "base");
2794 assert_eq!(remote_base, base);
2795 let tip = write_commit(&local, vec![base], "tip");
2796 let concurrent = write_commit(&remote, vec![base], "concurrent");
2797 set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
2798 let plan = PushActionPlan::from_actions(
2799 vec![PushAction::Update {
2800 dst: "refs/heads/main".into(),
2801 old: base,
2802 new: tip,
2803 }],
2804 default_options(),
2805 );
2806
2807 let err = push_local_actions(&local, &remote, &plan).expect_err("stale old rejects");
2808
2809 assert!(err.to_string().contains("expected ref refs/heads/main"));
2810 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2811 assert_eq!(
2812 remote_refs
2813 .read_ref("refs/heads/main")
2814 .expect("remote ref should read"),
2815 Some(RefTarget::Direct(concurrent))
2816 );
2817 }
2818
2819 #[test]
2820 fn local_push_actions_stale_delete_old_rejects_without_mutating() {
2821 let local = temp_repo("actions-delete-local");
2822 let remote = temp_repo("actions-delete-remote");
2823 let base = write_commit(&local, Vec::new(), "base");
2824 let remote_base = write_commit(&remote, Vec::new(), "base");
2825 assert_eq!(remote_base, base);
2826 let concurrent = write_commit(&remote, vec![base], "concurrent");
2827 set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
2828 let plan = PushActionPlan::from_actions(
2829 vec![PushAction::Delete {
2830 dst: "refs/heads/main".into(),
2831 old: Some(base),
2832 }],
2833 default_options(),
2834 );
2835
2836 let err = push_local_actions(&local, &remote, &plan).expect_err("stale delete rejects");
2837
2838 assert!(err.to_string().contains("expected ref refs/heads/main"));
2839 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2840 assert_eq!(
2841 remote_refs
2842 .read_ref("refs/heads/main")
2843 .expect("remote ref should read"),
2844 Some(RefTarget::Direct(concurrent))
2845 );
2846 }
2847
2848 #[test]
2849 fn local_push_actions_create_rejects_existing_ref() {
2850 let local = temp_repo("actions-create-local");
2851 let remote = temp_repo("actions-create-remote");
2852 let base = write_commit(&local, Vec::new(), "base");
2853 let remote_base = write_commit(&remote, Vec::new(), "base");
2854 assert_eq!(remote_base, base);
2855 let tip = write_commit(&local, vec![base], "tip");
2856 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2857 let plan = PushActionPlan::from_actions(
2858 vec![PushAction::Create {
2859 dst: "refs/heads/main".into(),
2860 new: tip,
2861 }],
2862 default_options(),
2863 );
2864
2865 let err = push_local_actions(&local, &remote, &plan).expect_err("create must be absent");
2866
2867 assert!(
2868 err.to_string()
2869 .contains("expected ref refs/heads/main to not already exist")
2870 );
2871 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2872 assert_eq!(
2873 remote_refs
2874 .read_ref("refs/heads/main")
2875 .expect("remote ref should read"),
2876 Some(RefTarget::Direct(base))
2877 );
2878 }
2879
2880 #[test]
2881 fn report_status_rejection_is_an_error() {
2882 let report = ReceivePackReportStatus {
2883 unpack: ReceivePackUnpackStatus::Ok,
2884 commands: vec![ReceivePackCommandStatus::Ng {
2885 name: "refs/heads/main".into(),
2886 message: "hook declined".into(),
2887 }],
2888 };
2889
2890 let err = validate_receive_pack_report(&report).expect_err("ng report should fail");
2891
2892 assert!(err.to_string().contains("hook declined"));
2893 }
2894
2895 #[test]
2896 fn failed_local_push_does_not_partially_mutate_remote_ref() {
2897 let local = temp_repo("local-rejected");
2898 let remote = temp_repo("remote-rejected");
2899 let base = write_commit(&local, Vec::new(), "base");
2900 let planned = write_commit(&local, vec![base], "planned");
2901 let concurrent = write_commit(&local, vec![base], "concurrent");
2902 set_ref(&local, "refs/heads/main", RefTarget::Direct(planned));
2903 set_ref(
2904 &local,
2905 "HEAD",
2906 RefTarget::Symbolic("refs/heads/main".into()),
2907 );
2908 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2909 let destination = PushDestination::Local {
2910 git_dir: remote.clone(),
2911 common_git_dir: remote.clone(),
2912 };
2913 let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
2914 let options = default_options();
2915 let request = PushRequest {
2916 git_dir: &local,
2917 common_git_dir: &local,
2918 format: ObjectFormat::Sha1,
2919 config: &GitConfig::default(),
2920 remote: "origin",
2921 destination: &destination,
2922 refspecs: &refspecs,
2923 options: &options,
2924 };
2925 let mut credentials = NoCredentials;
2926 let mut progress = SilentProgress;
2927 let mut services = PushServices {
2928 credentials: &mut credentials,
2929 progress: &mut progress,
2930 };
2931 let plan = plan_push(request, &mut services).expect("push should plan");
2932
2933 set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
2934 let _err = execute_push_plan(request, &mut services, plan)
2935 .expect_err("stale old id should reject the ref update");
2936
2937 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2938 assert_eq!(
2939 remote_refs
2940 .read_ref("refs/heads/main")
2941 .expect("remote ref should read"),
2942 Some(RefTarget::Direct(concurrent))
2943 );
2944 }
2945}