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, build_receive_pack_body};
45use sley_refs::{FileRefStore, Ref, RefTarget};
46use sley_transport::RemoteUrl;
47#[cfg(feature = "http")]
48use sley_transport::{HttpClient, 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 body = build_receive_pack_body(&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 mut response = crate::http::http_send_with_auth(&remote_url, credentials, |auth| {
785 client.post(
786 &url,
787 &content_type,
788 &crate::http::http_authorization_headers(auth),
789 &body,
790 )
791 })?;
792 crate::http::http_check_status(&response, &url)?;
793 crate::http::http_validate_content_type(
794 &response,
795 &smart_http_rpc_result_content_type(GitService::ReceivePack)?,
796 )?;
797
798 let report = if features.report_status {
799 let report = read_receive_pack_report_status(&mut response.body)?;
800 validate_receive_pack_report(&report)?;
801 Some(report)
802 } else {
803 let mut sink = Vec::new();
804 response.body.read_to_end(&mut sink)?;
805 None
806 };
807 Ok(PushOutcome { commands, report })
808}
809
810struct PushLocalRequest<'a> {
814 git_dir: &'a Path,
815 common_git_dir: &'a Path,
816 format: ObjectFormat,
817 remote: &'a str,
818 remote_git_dir: &'a Path,
819 remote_common_git_dir: &'a Path,
820 refspecs: &'a [String],
821 options: &'a PushOptions,
822}
823
824fn plan_push_local(request: PushLocalRequest<'_>) -> Result<PushPlan> {
825 let PushLocalRequest {
826 git_dir,
827 common_git_dir,
828 format,
829 remote,
830 remote_git_dir,
831 remote_common_git_dir,
832 refspecs,
833 options,
834 } = request;
835 let _ = remote;
836 let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
837 if remote_format != format {
838 return Err(GitError::InvalidObjectId(format!(
839 "remote repository uses {}, local repository uses {}",
840 remote_format.name(),
841 format.name()
842 )));
843 }
844
845 let local_store = FileRefStore::new(git_dir, format);
846 let mut local_refs = local_push_source_refs(&local_store, format)?;
847 add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
848 let remote_refs = crate::local::local_fetch_advertisements(remote_git_dir, format)?;
849 let command_forces =
850 plan_push_command_forces(format, &local_refs, &remote_refs, refspecs, options.force)?;
851 let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
852 reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
853 let commands = commands_from_forces(&command_forces);
854 let execution = if commands.is_empty() {
855 PushExecution::Noop
856 } else {
857 PushExecution::Local {
858 remote_git_dir: remote_git_dir.to_path_buf(),
859 remote_common_git_dir: remote_common_git_dir.to_path_buf(),
860 remote_refs,
861 command_forces,
862 pack_objects: Vec::new(),
863 }
864 };
865 Ok(PushPlan {
866 commands,
867 execution,
868 })
869}
870
871fn execute_push_local(
872 request: PushRequest<'_>,
873 commands: Vec<ReceivePackCommand>,
874 remote_git_dir: PathBuf,
875 remote_common_git_dir: PathBuf,
876 remote_refs: Vec<RefAdvertisement>,
877 _command_forces: Vec<(ReceivePackCommand, bool)>,
878 pack_objects: Vec<ObjectId>,
879) -> Result<PushOutcome> {
880 let remote_excluded_tips = remote_refs
881 .iter()
882 .map(|reference| reference.oid)
883 .collect::<Vec<_>>();
884 let starts = push_pack_roots(&commands, &pack_objects);
885 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
886 let remote_db = FileObjectDatabase::from_git_dir(&remote_common_git_dir, request.format);
887 let remote_excluded =
888 collect_reachable_object_ids(&remote_db, request.format, remote_excluded_tips)?;
889 let packfile = if starts.is_empty() {
890 Vec::new()
891 } else {
892 b"PACK".to_vec()
893 };
894 let receive_request = ReceivePackPushRequest {
895 commands: ReceivePackRequest {
896 shallow: Vec::new(),
897 commands: commands.clone(),
898 capabilities: Vec::new(),
899 },
900 push_options: None,
901 packfile,
902 };
903 let report = crate::local::receive_pack_reachable_pack_into_local_repository(
904 &remote_git_dir,
905 request.format,
906 &receive_request,
907 &local_db,
908 starts,
909 remote_excluded,
910 )?;
911 validate_receive_pack_report(&report)?;
912 Ok(PushOutcome {
913 commands,
914 report: Some(report),
915 })
916}
917
918pub struct PushReportRequest<'a> {
920 pub git_dir: &'a Path,
922 pub common_git_dir: &'a Path,
924 pub format: ObjectFormat,
926 pub remote_git_dir: &'a Path,
928 pub remote_common_git_dir: &'a Path,
930 pub refspecs: &'a [String],
932 pub force: bool,
934 pub atomic: bool,
936 pub dry_run: bool,
938 pub force_with_lease: &'a [(String, Option<ObjectId>)],
941 pub force_with_lease_default: bool,
946 pub force_if_includes: bool,
949 pub receive_config_overrides: &'a [(String, String)],
952}
953
954pub fn push_local_with_report(
962 request: PushReportRequest<'_>,
963 _config: &GitConfig,
964) -> Result<PushStatusReport> {
965 let format = request.format;
966 let remote_format = crate::object_format_for_git_dir(request.remote_common_git_dir)?;
967 if remote_format != format {
968 return Err(GitError::InvalidObjectId(format!(
969 "remote repository uses {}, local repository uses {}",
970 remote_format.name(),
971 format.name()
972 )));
973 }
974 let local_store = FileRefStore::new(request.git_dir, format);
975 let mut local_refs = local_push_source_refs(&local_store, format)?;
976 add_revision_push_sources(request.git_dir, format, request.refspecs, &mut local_refs);
977 let remote_refs = crate::local::local_fetch_advertisements(request.remote_git_dir, format)?;
978 let planned = plan_push_command_sources(
979 format,
980 &local_refs,
981 &remote_refs,
982 request.refspecs,
983 request.force,
984 )?;
985 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, format);
986 let remote_config =
987 sley_config::read_repo_config(request.remote_git_dir, None).unwrap_or_default();
988
989 let mut refs: Vec<PushReportRef> = Vec::new();
992 for plan in &planned {
993 let status = classify_push_command(
994 &local_db,
995 format,
996 plan,
997 &request,
998 &remote_config,
999 request.remote_git_dir,
1000 )?;
1001 let stale_lease_overridden = plan.force && lease_expectation_mismatch(&request, plan);
1004 let forced = matches!(status, PushRefStatus::Ok)
1005 && !plan.command.old_id.is_null()
1006 && !plan.command.new_id.is_null()
1007 && (stale_lease_overridden
1008 || if plan.command.name.starts_with("refs/heads/") {
1009 !is_fast_forward(
1010 &local_db,
1011 format,
1012 &plan.command.old_id,
1013 &plan.command.new_id,
1014 )?
1015 } else {
1016 plan.force
1017 });
1018 refs.push(PushReportRef {
1019 src: plan.source.clone(),
1020 dst: plan.command.name.clone(),
1021 old_id: plan.command.old_id,
1022 new_id: plan.command.new_id,
1023 forced,
1024 status,
1025 });
1026 }
1027
1028 let any_local_reject = refs.iter().any(|reference| {
1029 matches!(
1030 reference.status,
1031 PushRefStatus::RejectNonFastForward
1032 | PushRefStatus::RejectStale
1033 | PushRefStatus::RejectRemoteUpdated
1034 | PushRefStatus::RejectAlreadyExists
1035 )
1036 });
1037
1038 if request.atomic && any_local_reject {
1042 for reference in &mut refs {
1043 if matches!(reference.status, PushRefStatus::Ok) {
1044 reference.status = PushRefStatus::AtomicPushFailed;
1045 }
1046 }
1047 return Ok(PushStatusReport { refs });
1048 }
1049
1050 if request.dry_run {
1051 return Ok(PushStatusReport { refs });
1052 }
1053
1054 let send: Vec<ReceivePackCommand> = refs
1056 .iter()
1057 .filter(|reference| {
1058 matches!(reference.status, PushRefStatus::Ok) && reference.old_id != reference.new_id
1059 })
1060 .map(|reference| ReceivePackCommand {
1061 old_id: reference.old_id,
1062 new_id: reference.new_id,
1063 name: reference.dst.clone(),
1064 })
1065 .collect();
1066
1067 if !send.is_empty() {
1068 let remote_excluded_tips: Vec<ObjectId> =
1069 remote_refs.iter().map(|reference| reference.oid).collect();
1070 let pack_objects: Vec<ObjectId> = Vec::new();
1071 let starts = push_pack_roots(&send, &pack_objects);
1072 let remote_db = FileObjectDatabase::from_git_dir(request.remote_common_git_dir, format);
1073 let remote_excluded =
1074 collect_reachable_object_ids(&remote_db, format, remote_excluded_tips)?;
1075 let packfile = if starts.is_empty() {
1076 Vec::new()
1077 } else {
1078 b"PACK".to_vec()
1079 };
1080 let receive_request = ReceivePackPushRequest {
1081 commands: ReceivePackRequest {
1082 shallow: Vec::new(),
1083 commands: send.clone(),
1084 capabilities: Vec::new(),
1085 },
1086 push_options: None,
1087 packfile,
1088 };
1089 let report = crate::local::receive_pack_reachable_pack_into_local_repository(
1090 request.remote_git_dir,
1091 format,
1092 &receive_request,
1093 &local_db,
1094 starts,
1095 remote_excluded,
1096 )?;
1097 if let ReceivePackUnpackStatus::Error(message) = &report.unpack {
1099 for reference in &mut refs {
1100 if matches!(reference.status, PushRefStatus::Ok) {
1101 reference.status =
1102 PushRefStatus::RemoteReject(format!("unpacker error: {message}"));
1103 }
1104 }
1105 }
1106 for command_status in &report.commands {
1107 if let ReceivePackCommandStatus::Ng { name, message } = command_status {
1108 for reference in &mut refs {
1109 if reference.dst == *name && matches!(reference.status, PushRefStatus::Ok) {
1110 reference.status = PushRefStatus::RemoteReject(message.clone());
1111 }
1112 }
1113 }
1114 }
1115 }
1116
1117 Ok(PushStatusReport { refs })
1118}
1119
1120fn classify_push_command(
1124 local_db: &FileObjectDatabase,
1125 format: ObjectFormat,
1126 plan: &PlannedPushCommand,
1127 request: &PushReportRequest<'_>,
1128 config: &GitConfig,
1129 remote_git_dir: &Path,
1130) -> Result<PushRefStatus> {
1131 let command = &plan.command;
1132
1133 if receive_ref_is_hidden(config, request.receive_config_overrides, &command.name) {
1134 let reason = if command.new_id.is_null() {
1135 "deny deleting a hidden ref"
1136 } else {
1137 "deny updating a hidden ref"
1138 };
1139 return Ok(PushRefStatus::RemoteReject(reason.to_string()));
1140 }
1141
1142 if command.old_id == command.new_id && !command.new_id.is_null() {
1145 return Ok(PushRefStatus::UpToDate);
1146 }
1147
1148 if command.new_id.is_null() && !command.old_id.is_null() {
1149 if receive_config_bool(config, request.receive_config_overrides, "denydeletes")
1150 .unwrap_or(false)
1151 {
1152 return Ok(PushRefStatus::RemoteReject(
1153 "deletion prohibited".to_string(),
1154 ));
1155 }
1156 if receive_denies_current_branch_delete(format, command, config, request, remote_git_dir)? {
1157 return Ok(PushRefStatus::RemoteReject(
1158 "deletion of the current branch prohibited".to_string(),
1159 ));
1160 }
1161 }
1162
1163 if !request.dry_run && receive_denies_current_branch(format, command, config, remote_git_dir)? {
1164 return Ok(PushRefStatus::RemoteReject(
1165 "branch is currently checked out".to_string(),
1166 ));
1167 }
1168
1169 if command.name.starts_with("refs/heads/") && !command.new_id.is_null() {
1170 let object = local_db.read_object(&command.new_id)?;
1171 if object.object_type != ObjectType::Commit {
1172 return Ok(PushRefStatus::RemoteReject(
1173 "invalid new value provided".to_string(),
1174 ));
1175 }
1176 }
1177
1178 if let Some((_, expected)) = request
1182 .force_with_lease
1183 .iter()
1184 .find(|(dst, _)| *dst == command.name)
1185 {
1186 let actual = if command.old_id.is_null() {
1187 None
1188 } else {
1189 Some(command.old_id)
1190 };
1191 if *expected != actual {
1192 if plan.force {
1193 return Ok(PushRefStatus::Ok);
1194 }
1195 return Ok(PushRefStatus::RejectStale);
1196 }
1197 if request.force_if_includes
1198 && !command.old_id.is_null()
1199 && (command.new_id.is_null()
1200 || !is_fast_forward(local_db, format, &command.old_id, &command.new_id)?)
1201 && force_if_includes_rejects(
1202 local_db,
1203 format,
1204 request.git_dir,
1205 &command.name,
1206 &command.old_id,
1207 )?
1208 {
1209 if plan.force {
1210 return Ok(PushRefStatus::Ok);
1211 }
1212 return Ok(PushRefStatus::RejectRemoteUpdated);
1213 }
1214 return Ok(PushRefStatus::Ok);
1216 }
1217
1218 if command.name.starts_with("refs/heads/")
1219 && !command.old_id.is_null()
1220 && !command.new_id.is_null()
1221 && !is_fast_forward(local_db, format, &command.old_id, &command.new_id)?
1222 && receive_config_bool(
1223 config,
1224 request.receive_config_overrides,
1225 "denynonfastforwards",
1226 )
1227 .unwrap_or(false)
1228 {
1229 return Ok(PushRefStatus::RemoteReject(format!(
1230 "denying non-fast-forward {} (you should pull first)",
1231 command.name
1232 )));
1233 }
1234
1235 if !plan.force
1238 && command.name.starts_with("refs/tags/")
1239 && !command.old_id.is_null()
1240 && !command.new_id.is_null()
1241 {
1242 return Ok(PushRefStatus::RejectAlreadyExists);
1243 }
1244
1245 if !plan.force
1246 && command.name.starts_with("refs/heads/")
1247 && !command.old_id.is_null()
1248 && !command.new_id.is_null()
1249 && !is_fast_forward(local_db, format, &command.old_id, &command.new_id)?
1250 {
1251 return Ok(PushRefStatus::RejectNonFastForward);
1252 }
1253
1254 Ok(PushRefStatus::Ok)
1255}
1256
1257fn receive_ref_is_hidden(
1258 config: &GitConfig,
1259 overrides: &[(String, String)],
1260 refname: &str,
1261) -> bool {
1262 let mut hide_refs = Vec::new();
1263 hide_refs.extend(hidden_ref_values(config, "transfer", None));
1264 hide_refs.extend(hidden_ref_values(config, "receive", None));
1265 hide_refs.extend(
1266 overrides
1267 .iter()
1268 .filter(|(key, _)| key.eq_ignore_ascii_case("hiderefs"))
1269 .map(|(_, value)| trim_hidden_ref_pattern(value)),
1270 );
1271 ref_is_hidden_by_patterns(refname, &hide_refs)
1272}
1273
1274fn hidden_ref_values(config: &GitConfig, section: &str, subsection: Option<&str>) -> Vec<String> {
1275 config
1276 .get_all(section, subsection, "hiderefs")
1277 .into_iter()
1278 .flatten()
1279 .map(trim_hidden_ref_pattern)
1280 .collect()
1281}
1282
1283fn trim_hidden_ref_pattern(value: &str) -> String {
1284 value.trim_end_matches('/').to_string()
1285}
1286
1287fn ref_is_hidden_by_patterns(refname: &str, patterns: &[String]) -> bool {
1288 for pattern in patterns.iter().rev() {
1289 let mut pattern = pattern.as_str();
1290 let negated = pattern.strip_prefix('!').is_some();
1291 if negated {
1292 pattern = &pattern[1..];
1293 }
1294 if let Some(rest) = pattern.strip_prefix('^') {
1295 pattern = rest;
1296 }
1297 if hidden_ref_pattern_matches(refname, pattern) {
1298 return !negated;
1299 }
1300 }
1301 false
1302}
1303
1304fn hidden_ref_pattern_matches(refname: &str, pattern: &str) -> bool {
1305 refname
1306 .strip_prefix(pattern)
1307 .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
1308}
1309
1310fn lease_expectation_mismatch(request: &PushReportRequest<'_>, plan: &PlannedPushCommand) -> bool {
1311 let command = &plan.command;
1312 let actual = if command.old_id.is_null() {
1313 None
1314 } else {
1315 Some(command.old_id)
1316 };
1317 request
1318 .force_with_lease
1319 .iter()
1320 .find(|(dst, _)| *dst == command.name)
1321 .is_some_and(|(_, expected)| *expected != actual)
1322}
1323
1324fn force_if_includes_rejects(
1325 db: &FileObjectDatabase,
1326 format: ObjectFormat,
1327 git_dir: &Path,
1328 local_ref: &str,
1329 remote_old: &ObjectId,
1330) -> Result<bool> {
1331 let store = FileRefStore::new(git_dir, format);
1332 let mut candidates = Vec::new();
1333 match store.read_ref(local_ref)? {
1334 Some(RefTarget::Direct(oid)) => candidates.push(oid),
1335 Some(RefTarget::Symbolic(target)) => {
1336 if let Some(RefTarget::Direct(oid)) = store.read_ref(&target)? {
1337 candidates.push(oid);
1338 }
1339 }
1340 None => return Ok(false),
1341 }
1342 for entry in store.read_reflog(local_ref)? {
1343 if !entry.new_oid.is_null() {
1344 candidates.push(entry.new_oid);
1345 }
1346 }
1347 candidates.sort();
1348 candidates.dedup();
1349 for candidate in candidates {
1350 if candidate == *remote_old {
1351 return Ok(false);
1352 }
1353 if let Ok(ancestors) = ancestor_depths(db, format, &candidate)
1354 && ancestors.contains_key(remote_old)
1355 {
1356 return Ok(false);
1357 }
1358 }
1359 Ok(true)
1360}
1361
1362fn receive_config_bool(
1363 config: &GitConfig,
1364 overrides: &[(String, String)],
1365 key: &str,
1366) -> Option<bool> {
1367 overrides
1368 .iter()
1369 .rev()
1370 .find(|(candidate, _)| candidate.eq_ignore_ascii_case(key))
1371 .and_then(|(_, value)| sley_config::parse_config_bool(value))
1372 .or_else(|| config.get_bool("receive", None, key))
1373}
1374
1375fn receive_denies_current_branch(
1376 format: ObjectFormat,
1377 command: &ReceivePackCommand,
1378 config: &GitConfig,
1379 remote_git_dir: &Path,
1380) -> Result<bool> {
1381 if command.new_id.is_null() {
1382 return Ok(false);
1383 }
1384 if !command.name.starts_with("refs/heads/") {
1385 return Ok(false);
1386 }
1387 let deny = config
1388 .get("receive", None, "denycurrentbranch")
1389 .unwrap_or("refuse");
1390 let denies = matches!(
1391 deny.to_ascii_lowercase().as_str(),
1392 "true" | "yes" | "on" | "1" | "refuse"
1393 );
1394 if !denies {
1395 return Ok(false);
1396 }
1397 if sley_worktree::worktree_root_for_git_dir(remote_git_dir)?.is_none() {
1398 return Ok(false);
1399 }
1400 let store = FileRefStore::new(remote_git_dir, format);
1401 Ok(matches!(
1402 store.read_ref("HEAD")?,
1403 Some(RefTarget::Symbolic(target)) if target == command.name
1404 ))
1405}
1406
1407fn receive_targets_current_branch(
1408 format: ObjectFormat,
1409 command: &ReceivePackCommand,
1410 remote_git_dir: &Path,
1411) -> Result<bool> {
1412 if !command.name.starts_with("refs/heads/") {
1413 return Ok(false);
1414 }
1415 if sley_worktree::worktree_root_for_git_dir(remote_git_dir)?.is_none() {
1416 return Ok(false);
1417 }
1418 let store = FileRefStore::new(remote_git_dir, format);
1419 Ok(matches!(
1420 store.read_ref("HEAD")?,
1421 Some(RefTarget::Symbolic(target)) if target == command.name
1422 ))
1423}
1424
1425fn receive_denies_current_branch_delete(
1426 format: ObjectFormat,
1427 command: &ReceivePackCommand,
1428 config: &GitConfig,
1429 request: &PushReportRequest<'_>,
1430 remote_git_dir: &Path,
1431) -> Result<bool> {
1432 if !receive_targets_current_branch(format, command, remote_git_dir)? {
1433 return Ok(false);
1434 }
1435 let deny = request
1436 .receive_config_overrides
1437 .iter()
1438 .rev()
1439 .find(|(candidate, _)| candidate.eq_ignore_ascii_case("denydeletecurrent"))
1440 .map(|(_, value)| value.as_str())
1441 .or_else(|| config.get("receive", None, "denydeletecurrent"))
1442 .unwrap_or("refuse");
1443 Ok(!matches!(
1444 deny.to_ascii_lowercase().as_str(),
1445 "ignore" | "warn" | "false" | "no" | "off" | "0"
1446 ))
1447}
1448
1449fn is_fast_forward(
1452 db: &FileObjectDatabase,
1453 format: ObjectFormat,
1454 old: &ObjectId,
1455 new: &ObjectId,
1456) -> Result<bool> {
1457 let ancestors = ancestor_depths(db, format, new)?;
1458 Ok(ancestors.contains_key(old))
1459}
1460
1461#[cfg(feature = "http")]
1464fn advertised_receive_pack_features(
1465 advertisements: &[RefAdvertisement],
1466) -> Result<ReceivePackFeatures> {
1467 advertisements
1468 .first()
1469 .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
1470 .transpose()
1471 .map(Option::unwrap_or_default)
1472}
1473
1474#[cfg(feature = "http")]
1477fn verify_remote_object_format(features: &ReceivePackFeatures, format: ObjectFormat) -> Result<()> {
1478 if let Some(remote_format) = features.object_format {
1479 if remote_format != format {
1480 return Err(GitError::InvalidObjectId(format!(
1481 "remote repository uses {}, local repository uses {}",
1482 remote_format.name(),
1483 format.name()
1484 )));
1485 }
1486 } else if format != ObjectFormat::Sha1 {
1487 return Err(GitError::InvalidObjectId(format!(
1488 "remote repository did not advertise object-format for {} push",
1489 format.name()
1490 )));
1491 }
1492 Ok(())
1493}
1494
1495#[cfg(feature = "http")]
1500fn receive_pack_push_options(
1501 features: &ReceivePackFeatures,
1502 format: ObjectFormat,
1503 quiet: bool,
1504) -> ReceivePackPushRequestOptions {
1505 ReceivePackPushRequestOptions {
1506 report_status: features.report_status,
1507 ofs_delta: features.ofs_delta,
1508 quiet: quiet && features.quiet,
1509 object_format: features
1510 .object_format
1511 .filter(|_| format != ObjectFormat::Sha1),
1512 ..ReceivePackPushRequestOptions::default()
1513 }
1514}
1515
1516pub(crate) fn plan_push_command_forces(
1521 format: ObjectFormat,
1522 local_refs: &[PushSourceRef],
1523 remote_refs: &[RefAdvertisement],
1524 refspecs: &[String],
1525 force: bool,
1526) -> Result<Vec<(ReceivePackCommand, bool)>> {
1527 let parsed_refspecs = refspecs
1528 .iter()
1529 .map(|refspec| {
1530 let normalized = normalize_push_refspec_for_sources(refspec, local_refs, remote_refs)?;
1531 parse_refspec(&normalized)
1532 })
1533 .collect::<Result<Vec<_>>>()?;
1534 let mut command_forces = Vec::new();
1535 for refspec in &parsed_refspecs {
1536 for command in plan_push_commands(
1537 format,
1538 local_refs,
1539 remote_refs,
1540 std::slice::from_ref(refspec),
1541 )? {
1542 command_forces.push((command, force || refspec.force));
1543 }
1544 }
1545 Ok(command_forces)
1546}
1547
1548struct PlannedPushCommand {
1551 command: ReceivePackCommand,
1552 force: bool,
1553 source: Option<String>,
1554}
1555
1556fn plan_push_command_sources(
1562 format: ObjectFormat,
1563 local_refs: &[PushSourceRef],
1564 remote_refs: &[RefAdvertisement],
1565 refspecs: &[String],
1566 force: bool,
1567) -> Result<Vec<PlannedPushCommand>> {
1568 let mut planned = Vec::new();
1569 for refspec in refspecs {
1570 let normalized = normalize_push_refspec_for_sources(refspec, local_refs, remote_refs)?;
1571 let parsed = parse_refspec(&normalized)?;
1572 let commands = plan_push_commands(
1573 format,
1574 local_refs,
1575 remote_refs,
1576 std::slice::from_ref(&parsed),
1577 )?;
1578 for command in commands {
1579 let source = push_command_source_name(&parsed, &command);
1580 planned.push(PlannedPushCommand {
1581 command,
1582 force: force || parsed.force,
1583 source,
1584 });
1585 }
1586 }
1587 Ok(planned)
1588}
1589
1590fn push_command_source_name(refspec: &RefSpec, command: &ReceivePackCommand) -> Option<String> {
1595 let src = refspec.src.as_deref()?;
1596 if !refspec.pattern {
1597 return Some(src.to_string());
1598 }
1599 let (src_prefix, src_suffix) = src.split_once('*')?;
1600 let dst = refspec.dst.as_deref()?;
1601 let (dst_prefix, dst_suffix) = dst.split_once('*')?;
1602 let stem = command
1603 .name
1604 .strip_prefix(dst_prefix)
1605 .and_then(|rest| rest.strip_suffix(dst_suffix))?;
1606 Some(format!("{src_prefix}{stem}{src_suffix}"))
1607}
1608
1609pub(crate) fn add_revision_push_sources(
1610 git_dir: &Path,
1611 format: ObjectFormat,
1612 refspecs: &[String],
1613 local_refs: &mut Vec<PushSourceRef>,
1614) {
1615 for refspec in refspecs {
1616 let refspec = refspec.strip_prefix('+').unwrap_or(refspec);
1617 let src = refspec.split_once(':').map_or(refspec, |(src, _)| src);
1618 if src.is_empty() || src == "HEAD" {
1619 continue;
1620 }
1621 if src.starts_with("refs/") && local_refs.iter().any(|reference| reference.name == src) {
1622 continue;
1623 }
1624 if local_refs.iter().any(|reference| {
1625 reference.name == src
1626 || reference.name == format!("refs/heads/{src}")
1627 || reference.name == format!("refs/tags/{src}")
1628 }) {
1629 continue;
1630 }
1631 if let Ok(oid) = sley_rev::resolve_revision(git_dir, format, src)
1632 && !local_refs.iter().any(|reference| reference.name == src)
1633 {
1634 local_refs.push(PushSourceRef {
1635 name: src.to_string(),
1636 oid,
1637 });
1638 }
1639 }
1640}
1641
1642fn normalize_push_refspec_for_sources(
1643 refspec: &str,
1644 local_refs: &[PushSourceRef],
1645 remote_refs: &[RefAdvertisement],
1646) -> Result<String> {
1647 let (force, refspec) = refspec
1648 .strip_prefix('+')
1649 .map_or((false, refspec), |refspec| (true, refspec));
1650 let normalized = if let Some((src, dst)) = refspec.split_once(':') {
1651 let (src, src_kind) = normalize_push_source_refname(src, local_refs);
1652 let dst = if src.is_empty() {
1653 normalize_push_delete_destination_refname(dst, remote_refs)?
1654 } else {
1655 normalize_push_destination_refname(dst, src_kind, remote_refs)?
1656 };
1657 if !src.is_empty() && !dst.contains('*') && push_destination_is_onelevel_under_refs(&dst) {
1658 return Err(GitError::Command(format!(
1659 "destination refspec {dst} is not a valid ref"
1660 )));
1661 }
1662 format!("{src}:{dst}")
1663 } else {
1664 let (name, _) = normalize_push_source_refname(refspec, local_refs);
1665 let dst = match count_refspec_match_dst(&name, remote_refs) {
1672 DstMatch::Unique(matched) => matched.to_string(),
1673 DstMatch::None => name.clone(),
1674 DstMatch::Ambiguous => {
1675 return Err(GitError::Command(format!(
1676 "dst refspec {name} matches more than one"
1677 )));
1678 }
1679 };
1680 format!("{name}:{dst}")
1681 };
1682 Ok(if force {
1683 format!("+{normalized}")
1684 } else {
1685 normalized
1686 })
1687}
1688
1689fn refname_match_rank(abbrev: &str, full_name: &str) -> Option<usize> {
1693 const RULES: [&str; 6] = [
1694 "{}",
1695 "refs/{}",
1696 "refs/tags/{}",
1697 "refs/heads/{}",
1698 "refs/remotes/{}",
1699 "refs/remotes/{}/HEAD",
1700 ];
1701 for (idx, rule) in RULES.iter().enumerate() {
1702 let (prefix, suffix) = rule.split_once("{}").unwrap_or((rule, ""));
1703 if full_name == format!("{prefix}{abbrev}{suffix}") {
1704 return Some(RULES.len() - idx);
1705 }
1706 }
1707 None
1708}
1709
1710enum DstMatch<'a> {
1712 Unique(&'a str),
1714 None,
1716 Ambiguous,
1718}
1719
1720fn count_refspec_match_dst<'a>(pattern: &str, remote_refs: &'a [RefAdvertisement]) -> DstMatch<'a> {
1727 let patlen = pattern.len();
1728 let mut strong: Option<&str> = None;
1729 let mut strong_count = 0usize;
1730 let mut weak: Option<&str> = None;
1731 let mut weak_count = 0usize;
1732 for advert in remote_refs {
1733 let name = advert.name.as_str();
1734 if refname_match_rank(pattern, name).is_none() {
1735 continue;
1736 }
1737 let namelen = name.len();
1738 let is_weak = namelen != patlen
1739 && patlen + 5 != namelen
1740 && !name.starts_with("refs/heads/")
1741 && !name.starts_with("refs/tags/");
1742 if is_weak {
1743 weak = Some(name);
1744 weak_count += 1;
1745 } else {
1746 strong = Some(name);
1747 strong_count += 1;
1748 }
1749 }
1750 match (strong_count, weak_count, strong, weak) {
1751 (1, _, Some(matched), _) => DstMatch::Unique(matched),
1752 (0, 1, _, Some(matched)) => DstMatch::Unique(matched),
1753 (0, 0, _, _) => DstMatch::None,
1754 _ => DstMatch::Ambiguous,
1755 }
1756}
1757
1758#[derive(Clone, Copy)]
1759enum PushSourceKind {
1760 Branch,
1761 Tag,
1762 Other,
1766 Unqualifiable,
1770}
1771
1772fn normalize_push_source_refname(
1773 name: &str,
1774 local_refs: &[PushSourceRef],
1775) -> (String, PushSourceKind) {
1776 if name.is_empty() || name == "HEAD" || name == "@" || name.starts_with("refs/") {
1779 return (name.to_string(), PushSourceKind::Other);
1780 }
1781 let branch = format!("refs/heads/{name}");
1782 let tag = format!("refs/tags/{name}");
1783 let has_branch = local_refs.iter().any(|reference| reference.name == branch);
1784 let has_tag = local_refs.iter().any(|reference| reference.name == tag);
1785 if has_tag && !has_branch {
1786 (tag, PushSourceKind::Tag)
1787 } else if has_branch {
1788 (branch, PushSourceKind::Branch)
1789 } else if local_refs.iter().any(|reference| reference.name == name) {
1790 (name.to_string(), PushSourceKind::Unqualifiable)
1794 } else {
1795 (branch, PushSourceKind::Branch)
1796 }
1797}
1798
1799fn normalize_push_delete_destination_refname(
1800 name: &str,
1801 remote_refs: &[RefAdvertisement],
1802) -> Result<String> {
1803 if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
1804 return Ok(name.to_string());
1805 }
1806 match count_refspec_match_dst(name, remote_refs) {
1807 DstMatch::Unique(matched) => Ok(matched.to_string()),
1808 DstMatch::Ambiguous => Err(GitError::Command(format!(
1809 "dst refspec {name} matches more than one"
1810 ))),
1811 DstMatch::None => Err(GitError::reference_not_found(format!("remote ref {name}"))),
1812 }
1813}
1814
1815fn normalize_push_destination_refname(
1816 name: &str,
1817 src_kind: PushSourceKind,
1818 remote_refs: &[RefAdvertisement],
1819) -> Result<String> {
1820 if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
1821 return Ok(name.to_string());
1822 }
1823 match count_refspec_match_dst(name, remote_refs) {
1829 DstMatch::Unique(matched) => Ok(matched.to_string()),
1830 DstMatch::Ambiguous => Err(GitError::Command(format!(
1831 "dst refspec {name} matches more than one"
1832 ))),
1833 DstMatch::None => match src_kind {
1834 PushSourceKind::Tag => Ok(format!("refs/tags/{name}")),
1835 PushSourceKind::Branch | PushSourceKind::Other => Ok(format!("refs/heads/{name}")),
1836 PushSourceKind::Unqualifiable => Err(GitError::Command(format!(
1840 "the destination you provided is not a full refname (i.e., starting with \"refs/\"); unable to guess the destination for {name}"
1841 ))),
1842 },
1843 }
1844}
1845
1846fn push_destination_is_onelevel_under_refs(name: &str) -> bool {
1847 name.strip_prefix("refs/")
1848 .is_some_and(|rest| !rest.contains('/'))
1849}
1850
1851fn commands_from_forces(command_forces: &[(ReceivePackCommand, bool)]) -> Vec<ReceivePackCommand> {
1853 command_forces
1854 .iter()
1855 .map(|(command, _)| command.clone())
1856 .collect()
1857}
1858
1859fn receive_pack_commands_from_action_plan(
1860 format: ObjectFormat,
1861 plan: &PushActionPlan,
1862) -> Result<Vec<ReceivePackCommand>> {
1863 let zero = ObjectId::null(format);
1864 for oid in &plan.pack_objects {
1865 if oid.format() != format {
1866 return Err(GitError::InvalidObjectId(format!(
1867 "push pack object {oid} has {} object id for {} repository",
1868 oid.format().name(),
1869 format.name()
1870 )));
1871 }
1872 }
1873 plan.commands
1874 .iter()
1875 .map(|command| {
1876 let old_id = command.expected_old.unwrap_or(zero);
1877 let new_id = command.src.unwrap_or(zero);
1878 if old_id.format() != format {
1879 return Err(GitError::InvalidObjectId(format!(
1880 "push command {} expected old has {} object id for {} repository",
1881 command.dst,
1882 old_id.format().name(),
1883 format.name()
1884 )));
1885 }
1886 if new_id.format() != format {
1887 return Err(GitError::InvalidObjectId(format!(
1888 "push command {} new id has {} object id for {} repository",
1889 command.dst,
1890 new_id.format().name(),
1891 format.name()
1892 )));
1893 }
1894 Ok(ReceivePackCommand {
1895 old_id,
1896 new_id,
1897 name: command.dst.clone(),
1898 })
1899 })
1900 .collect()
1901}
1902
1903pub fn validate_receive_pack_report(report: &ReceivePackReportStatus) -> Result<()> {
1906 if let ReceivePackUnpackStatus::Error(message) = &report.unpack {
1907 return Err(GitError::Command(format!(
1908 "failed to push some refs: unpack failed: {message}"
1909 )));
1910 }
1911 for status in &report.commands {
1912 if let ReceivePackCommandStatus::Ng { name, message } = status {
1913 return Err(GitError::Command(format!(
1914 "failed to push {name}: {message}"
1915 )));
1916 }
1917 }
1918 Ok(())
1919}
1920
1921pub fn local_push_source_refs(
1925 store: &FileRefStore,
1926 format: ObjectFormat,
1927) -> Result<Vec<PushSourceRef>> {
1928 let mut refs = Vec::new();
1929 for reference in store.list_refs()? {
1930 let Some((oid, _)) = resolve_for_each_ref_target(store, &reference)? else {
1931 continue;
1932 };
1933 if oid.format() != format {
1934 return Err(GitError::InvalidObjectId(format!(
1935 "local ref {} has {} object id for {} repository",
1936 reference.name,
1937 oid.format().name(),
1938 format.name()
1939 )));
1940 }
1941 refs.push(PushSourceRef {
1942 name: reference.name.clone(),
1943 oid,
1944 });
1945 if let Some(short) = reference.name.strip_prefix("refs/heads/") {
1946 refs.push(PushSourceRef {
1947 name: short.to_string(),
1948 oid,
1949 });
1950 }
1951 if let Some(short) = reference.name.strip_prefix("refs/tags/") {
1952 refs.push(PushSourceRef {
1953 name: short.to_string(),
1954 oid,
1955 });
1956 }
1957 }
1958 if let Some(target) = store.read_ref("HEAD")? {
1959 let head = Ref {
1960 name: "HEAD".to_string(),
1961 target,
1962 };
1963 if let Some((oid, _)) = resolve_for_each_ref_target(store, &head)?
1964 && oid.format() == format
1965 {
1966 refs.push(PushSourceRef {
1967 name: "HEAD".to_string(),
1968 oid,
1969 });
1970 }
1971 }
1972 Ok(refs)
1973}
1974
1975pub fn normalize_push_refspec(refspec: &str) -> String {
1979 let (force, refspec) = refspec
1980 .strip_prefix('+')
1981 .map_or((false, refspec), |refspec| (true, refspec));
1982 let normalized = if let Some((src, dst)) = refspec.split_once(':') {
1983 let src = normalize_push_refname(src);
1984 let dst = normalize_push_refname(dst);
1985 format!("{src}:{dst}")
1986 } else {
1987 let name = normalize_push_refname(refspec);
1988 format!("{name}:{name}")
1989 };
1990 if force {
1991 format!("+{normalized}")
1992 } else {
1993 normalized
1994 }
1995}
1996
1997pub fn normalize_push_refname(name: &str) -> String {
2000 if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
2001 name.to_string()
2002 } else {
2003 format!("refs/heads/{name}")
2004 }
2005}
2006
2007pub fn reject_non_fast_forward_pushes(
2011 local_db: &FileObjectDatabase,
2012 format: ObjectFormat,
2013 command_forces: &[(ReceivePackCommand, bool)],
2014) -> Result<()> {
2015 for (command, force) in command_forces {
2016 if *force
2017 || !command.name.starts_with("refs/heads/")
2018 || command.old_id.is_null()
2019 || command.new_id.is_null()
2020 {
2021 continue;
2022 }
2023 let ancestors = ancestor_depths(local_db, format, &command.new_id)?;
2024 if !ancestors.contains_key(&command.old_id) {
2025 let short = command.name.trim_start_matches("refs/heads/");
2026 return Err(GitError::Command(format!(
2027 "failed to push some refs: non-fast-forward update to {short}"
2028 )));
2029 }
2030 }
2031 Ok(())
2032}
2033
2034fn ancestor_depths(
2038 db: &FileObjectDatabase,
2039 format: ObjectFormat,
2040 start: &ObjectId,
2041) -> Result<HashMap<ObjectId, usize>> {
2042 let mut depths = HashMap::new();
2043 let mut pending = std::collections::VecDeque::from([(start.clone(), 0usize)]);
2044 while let Some((oid, depth)) = pending.pop_front() {
2045 if depths.get(&oid).is_some_and(|existing| *existing <= depth) {
2046 continue;
2047 }
2048 depths.insert(oid, depth);
2049 let object = db.read_object(&oid)?;
2050 if object.object_type != ObjectType::Commit {
2051 return Err(GitError::InvalidObject(format!(
2052 "expected commit {oid}, found {}",
2053 object.object_type.as_str()
2054 )));
2055 }
2056 let commit = Commit::parse_ref(format, &object.body)?;
2057 for parent in commit.parents {
2058 pending.push_back((parent, depth + 1));
2059 }
2060 }
2061 Ok(depths)
2062}
2063
2064fn resolve_for_each_ref_target(
2067 store: &FileRefStore,
2068 reference: &Ref,
2069) -> Result<Option<(ObjectId, Option<String>)>> {
2070 let mut target = reference.target.clone();
2071 let mut symref = None;
2072 for _ in 0..5 {
2073 match target {
2074 RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
2075 RefTarget::Symbolic(name) => {
2076 symref.get_or_insert_with(|| name.clone());
2077 let Some(next) = store.read_ref(&name)? else {
2078 return Ok(None);
2079 };
2080 target = next;
2081 }
2082 }
2083 }
2084 Ok(None)
2085}
2086
2087#[cfg(test)]
2088mod tests {
2089 use super::*;
2090 use std::fs;
2091 use std::sync::atomic::{AtomicU64, Ordering};
2092
2093 use sley_formats::RepositoryLayout;
2094 use sley_object::{Commit, EncodedObject, ObjectType, Tree};
2095 use sley_odb::{FileObjectDatabase, ObjectWriter};
2096 use sley_protocol::{ReceivePackCommandStatus, ReceivePackUnpackStatus};
2097 use sley_refs::{RefTarget, RefUpdate};
2098
2099 use crate::{NoCredentials, SilentProgress};
2100
2101 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
2102
2103 fn temp_repo(name: &str) -> PathBuf {
2104 let dir = std::env::temp_dir().join(format!(
2105 "sley-remote-push-{name}-{}-{}",
2106 std::process::id(),
2107 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
2108 ));
2109 let _ = fs::remove_dir_all(&dir);
2110 RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
2111 .expect("test repository should initialize");
2112 dir.join(".git")
2113 }
2114
2115 fn write_commit(git_dir: &Path, parents: Vec<ObjectId>, message: &str) -> ObjectId {
2116 let format = ObjectFormat::Sha1;
2117 let db = FileObjectDatabase::from_git_dir(git_dir, format);
2118 let tree = db
2119 .write_object(EncodedObject::new(
2120 ObjectType::Tree,
2121 Tree { entries: vec![] }.write(),
2122 ))
2123 .expect("tree should write");
2124 let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
2125 db.write_object(EncodedObject::new(
2126 ObjectType::Commit,
2127 Commit {
2128 tree,
2129 parents,
2130 author: identity.clone(),
2131 committer: identity,
2132 encoding: None,
2133 message: format!("{message}\n").into_bytes(),
2134 }
2135 .write(),
2136 ))
2137 .expect("commit should write")
2138 }
2139
2140 fn set_ref(git_dir: &Path, name: &str, target: RefTarget) {
2141 let store = FileRefStore::new(git_dir, ObjectFormat::Sha1);
2142 let mut tx = store.transaction();
2143 tx.update(RefUpdate {
2144 name: name.to_string(),
2145 expected: None,
2146 new: target,
2147 reflog: None,
2148 });
2149 tx.commit().expect("ref should update");
2150 }
2151
2152 fn default_options() -> PushOptions {
2153 PushOptions {
2154 quiet: true,
2155 force: false,
2156 }
2157 }
2158
2159 #[test]
2160 fn push_action_plan_infers_pack_roots_from_non_delete_commands() {
2161 let repo = temp_repo("action-plan-infer-roots");
2162 let first = write_commit(&repo, Vec::new(), "first");
2163 let second = write_commit(&repo, vec![first], "second");
2164
2165 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
2166 vec![
2167 PushCommand {
2168 src: Some(first),
2169 dst: "refs/heads/main".into(),
2170 expected_old: None,
2171 force: false,
2172 },
2173 PushCommand {
2174 src: Some(second),
2175 dst: "refs/heads/topic".into(),
2176 expected_old: Some(first),
2177 force: true,
2178 },
2179 ],
2180 default_options(),
2181 );
2182
2183 assert_eq!(plan.pack_objects, vec![first, second]);
2184 assert!(!plan.commands[0].force);
2185 assert!(plan.commands[1].force);
2186 }
2187
2188 #[test]
2189 fn push_action_plan_inferred_pack_roots_exclude_deletes() {
2190 let repo = temp_repo("action-plan-delete-roots");
2191 let old = write_commit(&repo, Vec::new(), "old");
2192 let new = write_commit(&repo, vec![old], "new");
2193
2194 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
2195 vec![
2196 PushCommand {
2197 src: None,
2198 dst: "refs/heads/remove".into(),
2199 expected_old: Some(old),
2200 force: false,
2201 },
2202 PushCommand {
2203 src: Some(new),
2204 dst: "refs/heads/keep".into(),
2205 expected_old: Some(old),
2206 force: false,
2207 },
2208 ],
2209 default_options(),
2210 );
2211
2212 assert_eq!(plan.pack_objects, vec![new]);
2213 }
2214
2215 #[test]
2216 fn push_action_plan_inferred_pack_roots_dedupe_first_seen_order() {
2217 let repo = temp_repo("action-plan-dedupe-roots");
2218 let first = write_commit(&repo, Vec::new(), "first");
2219 let second = write_commit(&repo, Vec::new(), "second");
2220
2221 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
2222 vec![
2223 PushCommand {
2224 src: Some(second),
2225 dst: "refs/heads/second".into(),
2226 expected_old: None,
2227 force: false,
2228 },
2229 PushCommand {
2230 src: Some(first),
2231 dst: "refs/heads/first".into(),
2232 expected_old: None,
2233 force: false,
2234 },
2235 PushCommand {
2236 src: Some(second),
2237 dst: "refs/tags/second".into(),
2238 expected_old: None,
2239 force: false,
2240 },
2241 PushCommand {
2242 src: Some(first),
2243 dst: "refs/tags/first".into(),
2244 expected_old: None,
2245 force: false,
2246 },
2247 ],
2248 default_options(),
2249 );
2250
2251 assert_eq!(plan.pack_objects, vec![second, first]);
2252 }
2253
2254 fn push_local_actions(
2255 local: &Path,
2256 remote: &Path,
2257 plan: &PushActionPlan,
2258 ) -> Result<PushOutcome> {
2259 let destination = PushDestination::Local {
2260 git_dir: remote.to_path_buf(),
2261 common_git_dir: remote.to_path_buf(),
2262 };
2263 let config = GitConfig::default();
2264 let mut credentials = NoCredentials;
2265 let mut progress = SilentProgress;
2266 push_actions(
2267 PushActionRequest {
2268 git_dir: local,
2269 common_git_dir: local,
2270 format: ObjectFormat::Sha1,
2271 config: &config,
2272 remote: "origin",
2273 destination: &destination,
2274 plan,
2275 },
2276 PushServices {
2277 credentials: &mut credentials,
2278 progress: &mut progress,
2279 },
2280 )
2281 }
2282
2283 #[test]
2284 fn local_push_returns_success_report_status_and_updates_ref() {
2285 let local = temp_repo("local-success");
2286 let remote = temp_repo("remote-success");
2287 let base = write_commit(&local, Vec::new(), "base");
2288 let tip = write_commit(&local, vec![base], "tip");
2289 set_ref(&local, "refs/heads/main", RefTarget::Direct(tip));
2290 set_ref(
2291 &local,
2292 "HEAD",
2293 RefTarget::Symbolic("refs/heads/main".into()),
2294 );
2295 let destination = PushDestination::Local {
2296 git_dir: remote.clone(),
2297 common_git_dir: remote.clone(),
2298 };
2299 let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
2300 let options = default_options();
2301 let request = PushRequest {
2302 git_dir: &local,
2303 common_git_dir: &local,
2304 format: ObjectFormat::Sha1,
2305 config: &GitConfig::default(),
2306 remote: "origin",
2307 destination: &destination,
2308 refspecs: &refspecs,
2309 options: &options,
2310 };
2311 let mut credentials = NoCredentials;
2312 let mut progress = SilentProgress;
2313
2314 let outcome = push(
2315 request,
2316 PushServices {
2317 credentials: &mut credentials,
2318 progress: &mut progress,
2319 },
2320 )
2321 .expect("push should succeed");
2322
2323 assert_eq!(outcome.commands.len(), 1);
2324 let report = outcome.report.expect("local receive-pack reports status");
2325 assert!(matches!(report.unpack, ReceivePackUnpackStatus::Ok));
2326 assert!(matches!(
2327 report.commands.as_slice(),
2328 [ReceivePackCommandStatus::Ok { name }] if name == "refs/heads/main"
2329 ));
2330 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2331 assert_eq!(
2332 remote_refs
2333 .read_ref("refs/heads/main")
2334 .expect("remote ref should read"),
2335 Some(RefTarget::Direct(tip))
2336 );
2337 }
2338
2339 #[test]
2340 fn local_push_actions_preserves_exact_old_new_update() {
2341 let local = temp_repo("actions-update-local");
2342 let remote = temp_repo("actions-update-remote");
2343 let base = write_commit(&local, Vec::new(), "base");
2344 let remote_base = write_commit(&remote, Vec::new(), "base");
2345 assert_eq!(remote_base, base);
2346 let tip = write_commit(&local, vec![base], "tip");
2347 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2348 let plan = PushActionPlan::from_actions(
2349 vec![PushAction::Update {
2350 dst: "refs/heads/main".into(),
2351 old: base,
2352 new: tip,
2353 }],
2354 default_options(),
2355 );
2356
2357 let outcome = push_local_actions(&local, &remote, &plan).expect("push actions");
2358
2359 assert_eq!(outcome.commands.len(), 1);
2360 assert_eq!(outcome.commands[0].old_id, base);
2361 assert_eq!(outcome.commands[0].new_id, tip);
2362 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2363 assert_eq!(
2364 remote_refs
2365 .read_ref("refs/heads/main")
2366 .expect("remote ref should read"),
2367 Some(RefTarget::Direct(tip))
2368 );
2369 }
2370
2371 #[test]
2372 fn local_push_actions_honors_per_command_force() {
2373 let local = temp_repo("actions-command-force-local");
2374 let remote = temp_repo("actions-command-force-remote");
2375 let base = write_commit(&local, Vec::new(), "base");
2376 let remote_base = write_commit(&remote, Vec::new(), "base");
2377 assert_eq!(remote_base, base);
2378 let unrelated = write_commit(&local, Vec::new(), "unrelated");
2379 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2380
2381 let unforced = PushActionPlan::from_commands(
2382 vec![PushCommand {
2383 src: Some(unrelated),
2384 dst: "refs/heads/main".into(),
2385 expected_old: Some(base),
2386 force: false,
2387 }],
2388 default_options(),
2389 );
2390 let err = push_local_actions(&local, &remote, &unforced)
2391 .expect_err("non-fast-forward should reject without command force");
2392 assert!(err.to_string().contains("non-fast-forward"));
2393
2394 let forced = PushActionPlan::from_commands(
2395 vec![PushCommand {
2396 src: Some(unrelated),
2397 dst: "refs/heads/main".into(),
2398 expected_old: Some(base),
2399 force: true,
2400 }],
2401 default_options(),
2402 );
2403 let outcome = push_local_actions(&local, &remote, &forced).expect("command force pushes");
2404
2405 assert_eq!(outcome.commands.len(), 1);
2406 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2407 assert_eq!(
2408 remote_refs
2409 .read_ref("refs/heads/main")
2410 .expect("remote ref should read"),
2411 Some(RefTarget::Direct(unrelated))
2412 );
2413 }
2414
2415 #[test]
2416 fn local_push_actions_command_force_is_precise_for_non_ff_validation() {
2417 let local = temp_repo("actions-command-force-precise-local");
2418 let remote = temp_repo("actions-command-force-precise-remote");
2419 let base = write_commit(&local, Vec::new(), "base");
2420 let remote_base = write_commit(&remote, Vec::new(), "base");
2421 assert_eq!(remote_base, base);
2422 let forced_unrelated = write_commit(&local, Vec::new(), "forced unrelated");
2423 let unforced_unrelated = write_commit(&local, Vec::new(), "unforced unrelated");
2424 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2425 set_ref(&remote, "refs/heads/topic", RefTarget::Direct(base));
2426 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
2427 vec![
2428 PushCommand {
2429 src: Some(forced_unrelated),
2430 dst: "refs/heads/main".into(),
2431 expected_old: Some(base),
2432 force: true,
2433 },
2434 PushCommand {
2435 src: Some(unforced_unrelated),
2436 dst: "refs/heads/topic".into(),
2437 expected_old: Some(base),
2438 force: false,
2439 },
2440 ],
2441 default_options(),
2442 );
2443
2444 let err = push_local_actions(&local, &remote, &plan)
2445 .expect_err("only the forced command should bypass non-fast-forward validation");
2446
2447 assert!(err.to_string().contains("non-fast-forward update to topic"));
2448 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2449 assert_eq!(
2450 remote_refs
2451 .read_ref("refs/heads/main")
2452 .expect("remote ref should read"),
2453 Some(RefTarget::Direct(base))
2454 );
2455 assert_eq!(
2456 remote_refs
2457 .read_ref("refs/heads/topic")
2458 .expect("remote ref should read"),
2459 Some(RefTarget::Direct(base))
2460 );
2461 }
2462
2463 #[test]
2464 fn local_push_actions_stale_update_old_rejects_without_mutating() {
2465 let local = temp_repo("actions-stale-local");
2466 let remote = temp_repo("actions-stale-remote");
2467 let base = write_commit(&local, Vec::new(), "base");
2468 let remote_base = write_commit(&remote, Vec::new(), "base");
2469 assert_eq!(remote_base, base);
2470 let tip = write_commit(&local, vec![base], "tip");
2471 let concurrent = write_commit(&remote, vec![base], "concurrent");
2472 set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
2473 let plan = PushActionPlan::from_actions(
2474 vec![PushAction::Update {
2475 dst: "refs/heads/main".into(),
2476 old: base,
2477 new: tip,
2478 }],
2479 default_options(),
2480 );
2481
2482 let err = push_local_actions(&local, &remote, &plan).expect_err("stale old rejects");
2483
2484 assert!(err.to_string().contains("expected ref refs/heads/main"));
2485 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2486 assert_eq!(
2487 remote_refs
2488 .read_ref("refs/heads/main")
2489 .expect("remote ref should read"),
2490 Some(RefTarget::Direct(concurrent))
2491 );
2492 }
2493
2494 #[test]
2495 fn local_push_actions_stale_delete_old_rejects_without_mutating() {
2496 let local = temp_repo("actions-delete-local");
2497 let remote = temp_repo("actions-delete-remote");
2498 let base = write_commit(&local, Vec::new(), "base");
2499 let remote_base = write_commit(&remote, Vec::new(), "base");
2500 assert_eq!(remote_base, base);
2501 let concurrent = write_commit(&remote, vec![base], "concurrent");
2502 set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
2503 let plan = PushActionPlan::from_actions(
2504 vec![PushAction::Delete {
2505 dst: "refs/heads/main".into(),
2506 old: Some(base),
2507 }],
2508 default_options(),
2509 );
2510
2511 let err = push_local_actions(&local, &remote, &plan).expect_err("stale delete rejects");
2512
2513 assert!(err.to_string().contains("expected ref refs/heads/main"));
2514 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2515 assert_eq!(
2516 remote_refs
2517 .read_ref("refs/heads/main")
2518 .expect("remote ref should read"),
2519 Some(RefTarget::Direct(concurrent))
2520 );
2521 }
2522
2523 #[test]
2524 fn local_push_actions_create_rejects_existing_ref() {
2525 let local = temp_repo("actions-create-local");
2526 let remote = temp_repo("actions-create-remote");
2527 let base = write_commit(&local, Vec::new(), "base");
2528 let remote_base = write_commit(&remote, Vec::new(), "base");
2529 assert_eq!(remote_base, base);
2530 let tip = write_commit(&local, vec![base], "tip");
2531 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2532 let plan = PushActionPlan::from_actions(
2533 vec![PushAction::Create {
2534 dst: "refs/heads/main".into(),
2535 new: tip,
2536 }],
2537 default_options(),
2538 );
2539
2540 let err = push_local_actions(&local, &remote, &plan).expect_err("create must be absent");
2541
2542 assert!(
2543 err.to_string()
2544 .contains("expected ref refs/heads/main to not already exist")
2545 );
2546 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2547 assert_eq!(
2548 remote_refs
2549 .read_ref("refs/heads/main")
2550 .expect("remote ref should read"),
2551 Some(RefTarget::Direct(base))
2552 );
2553 }
2554
2555 #[test]
2556 fn report_status_rejection_is_an_error() {
2557 let report = ReceivePackReportStatus {
2558 unpack: ReceivePackUnpackStatus::Ok,
2559 commands: vec![ReceivePackCommandStatus::Ng {
2560 name: "refs/heads/main".into(),
2561 message: "hook declined".into(),
2562 }],
2563 };
2564
2565 let err = validate_receive_pack_report(&report).expect_err("ng report should fail");
2566
2567 assert!(err.to_string().contains("hook declined"));
2568 }
2569
2570 #[test]
2571 fn failed_local_push_does_not_partially_mutate_remote_ref() {
2572 let local = temp_repo("local-rejected");
2573 let remote = temp_repo("remote-rejected");
2574 let base = write_commit(&local, Vec::new(), "base");
2575 let planned = write_commit(&local, vec![base], "planned");
2576 let concurrent = write_commit(&local, vec![base], "concurrent");
2577 set_ref(&local, "refs/heads/main", RefTarget::Direct(planned));
2578 set_ref(
2579 &local,
2580 "HEAD",
2581 RefTarget::Symbolic("refs/heads/main".into()),
2582 );
2583 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2584 let destination = PushDestination::Local {
2585 git_dir: remote.clone(),
2586 common_git_dir: remote.clone(),
2587 };
2588 let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
2589 let options = default_options();
2590 let request = PushRequest {
2591 git_dir: &local,
2592 common_git_dir: &local,
2593 format: ObjectFormat::Sha1,
2594 config: &GitConfig::default(),
2595 remote: "origin",
2596 destination: &destination,
2597 refspecs: &refspecs,
2598 options: &options,
2599 };
2600 let mut credentials = NoCredentials;
2601 let mut progress = SilentProgress;
2602 let mut services = PushServices {
2603 credentials: &mut credentials,
2604 progress: &mut progress,
2605 };
2606 let plan = plan_push(request, &mut services).expect("push should plan");
2607
2608 set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
2609 let _err = execute_push_plan(request, &mut services, plan)
2610 .expect_err("stale old id should reject the ref update");
2611
2612 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2613 assert_eq!(
2614 remote_refs
2615 .read_ref("refs/heads/main")
2616 .expect("remote ref should read"),
2617 Some(RefTarget::Direct(concurrent))
2618 );
2619 }
2620}