1use std::fs;
22use std::io::{Cursor, Read};
23use std::path::{Path, PathBuf};
24use std::time::{SystemTime, UNIX_EPOCH};
25
26use crate::proc_receive::ProcReceiveReport;
27use sley_config::GitConfig;
28use sley_core::{GitError, ObjectFormat, ObjectId, Result, redact_url_for_display};
29use sley_object::ObjectType;
30use sley_odb::{
31 FileObjectDatabase, ObjectReader, RawPackInstallOptions, build_and_install_reachable_pack,
32 collect_reachable_object_ids, collect_reachable_object_ids_tolerating_promised_missing,
33};
34#[cfg(feature = "http")]
35use sley_protocol::{
36 GitService, parse_receive_pack_features, smart_http_rpc_request_content_type,
37 smart_http_rpc_result_content_type,
38};
39use sley_protocol::{
40 PushSourceRef, ReceivePackCommand, ReceivePackCommandStatus, ReceivePackCommandStatusV2,
41 ReceivePackCommandStatusV2Options, ReceivePackFeatures, ReceivePackPushRequest,
42 ReceivePackPushRequestHeader, ReceivePackPushRequestOptions, ReceivePackReportStatus,
43 ReceivePackReportStatusV2, ReceivePackRequest, ReceivePackUnpackStatus, RefAdvertisement,
44 RefSpec, parse_refspec, plan_push_commands, read_and_demux_sideband_stream,
45 read_receive_pack_report_status, read_receive_pack_report_status_v2,
46};
47
48use crate::pack::push_pack_roots;
49#[cfg(feature = "http")]
50use crate::pack::write_receive_pack_body;
51use crate::pack::{PushPackRequest, write_push_packfile};
52use crate::proc_receive::{
53 ReceivePackCommandState, parse_proc_receive_refs, proc_receive_ref_matches,
54};
55
56use crate::receive_pack_server::{
57 ReceivePackServerOptions, ReceivePackServerReport, ReceivePackServerRequest, serve_receive_pack,
58};
59use sley_refs::{FileRefStore, Ref, RefTarget};
60use sley_transport::RemoteUrl;
61#[cfg(feature = "http")]
62use sley_transport::{HttpClient, HttpResponse, http_smart_rpc_url};
63
64use crate::{CredentialProvider, ProgressSink};
65
66pub enum PushDestination {
72 Http(RemoteUrl),
74 Ssh(RemoteUrl),
77 Git(RemoteUrl),
79 Local {
81 git_dir: PathBuf,
83 common_git_dir: PathBuf,
85 },
86}
87
88#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
90pub enum PushThinMode {
91 #[default]
94 Auto,
95 Always,
97 Never,
99}
100
101impl PushThinMode {
102 pub(crate) fn wants_thin(self) -> bool {
103 !matches!(self, Self::Never)
104 }
105}
106
107#[derive(Debug, Clone, Default)]
115pub struct PushOptions {
116 pub quiet: bool,
120 pub force: bool,
123 pub thin: PushThinMode,
125 pub atomic: bool,
127 pub push_options: Vec<String>,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct PushCommand {
134 pub src: Option<ObjectId>,
136 pub dst: String,
138 pub expected_old: Option<ObjectId>,
142 pub force: bool,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
150pub enum PushAction {
151 Create {
152 dst: String,
153 new: ObjectId,
154 },
155 Update {
156 dst: String,
157 old: ObjectId,
158 new: ObjectId,
159 },
160 Delete {
161 dst: String,
162 old: Option<ObjectId>,
163 },
164}
165
166impl From<PushAction> for PushCommand {
167 fn from(value: PushAction) -> Self {
168 match value {
169 PushAction::Create { dst, new } => Self {
170 src: Some(new),
171 dst,
172 expected_old: None,
173 force: false,
174 },
175 PushAction::Update { dst, old, new } => Self {
176 src: Some(new),
177 dst,
178 expected_old: Some(old),
179 force: false,
180 },
181 PushAction::Delete { dst, old } => Self {
182 src: None,
183 dst,
184 expected_old: old,
185 force: false,
186 },
187 }
188 }
189}
190
191#[derive(Debug, Clone)]
194pub struct PushActionPlan {
195 pub commands: Vec<PushCommand>,
196 pub pack_objects: Vec<ObjectId>,
197 pub options: PushOptions,
198}
199
200impl PushActionPlan {
201 pub fn from_actions(actions: Vec<PushAction>, options: PushOptions) -> Self {
202 Self {
203 commands: actions.into_iter().map(PushCommand::from).collect(),
204 pack_objects: Vec::new(),
205 options,
206 }
207 }
208
209 pub fn from_commands(commands: Vec<PushCommand>, options: PushOptions) -> Self {
210 Self {
211 commands,
212 pack_objects: Vec::new(),
213 options,
214 }
215 }
216
217 pub fn from_commands_and_infer_pack_roots(
218 commands: Vec<PushCommand>,
219 options: PushOptions,
220 ) -> Self {
221 let mut pack_objects = Vec::new();
222 for command in &commands {
223 let Some(src) = command.src.as_ref() else {
224 continue;
225 };
226 if !pack_objects.contains(src) {
227 pack_objects.push(*src);
228 }
229 }
230 Self {
231 commands,
232 pack_objects,
233 options,
234 }
235 }
236}
237
238#[derive(Debug, Clone, PartialEq, Eq)]
240pub enum ReceivePackPushReport {
241 V1(ReceivePackReportStatus),
242 V2(ReceivePackReportStatusV2),
243}
244
245impl ReceivePackPushReport {
246 pub fn unpack_failed(&self) -> Option<&str> {
247 match self {
248 Self::V1(report) => match &report.unpack {
249 ReceivePackUnpackStatus::Ok => None,
250 ReceivePackUnpackStatus::Error(message) => Some(message.as_str()),
251 },
252 Self::V2(report) => match &report.unpack {
253 ReceivePackUnpackStatus::Ok => None,
254 ReceivePackUnpackStatus::Error(message) => Some(message.as_str()),
255 },
256 }
257 }
258}
259
260#[derive(Debug, Clone, Default)]
262pub struct PushOutcome {
263 pub commands: Vec<ReceivePackCommand>,
268 pub report: Option<ReceivePackPushReport>,
270 pub remote_progress: Vec<u8>,
272}
273
274#[derive(Debug, Clone, PartialEq, Eq)]
279pub enum PushRefStatus {
280 Ok,
282 UpToDate,
284 RejectNonFastForward,
286 RejectFetchFirst,
288 RejectStale,
290 RejectRemoteUpdated,
292 RejectAlreadyExists,
294 RemoteReject(String),
296 AtomicPushFailed,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq)]
305pub struct PushReportRef {
306 pub src: Option<String>,
309 pub dst: String,
311 pub old_id: ObjectId,
313 pub new_id: ObjectId,
315 pub forced: bool,
317 pub status: PushRefStatus,
319 pub reports: Vec<ProcReceiveReport>,
321}
322
323impl PushReportRef {
324 pub fn tracking_commands(&self) -> Vec<ReceivePackCommand> {
327 if self.reports.is_empty() {
328 return vec![ReceivePackCommand {
329 old_id: self.old_id,
330 new_id: self.new_id,
331 name: self.dst.clone(),
332 }];
333 }
334 self.reports
335 .iter()
336 .map(|report| ReceivePackCommand {
337 old_id: report.old_oid.unwrap_or(self.old_id),
338 new_id: report.new_oid.unwrap_or(self.new_id),
339 name: report.refname.clone().unwrap_or_else(|| self.dst.clone()),
340 })
341 .collect()
342 }
343
344 pub fn is_deletion(&self) -> bool {
346 self.new_id.is_null()
347 }
348
349 pub fn had_error(&self) -> bool {
352 !matches!(self.status, PushRefStatus::Ok | PushRefStatus::UpToDate)
353 }
354}
355
356#[derive(Debug, Clone, Default, PartialEq, Eq)]
360pub struct PushStatusReport {
361 pub refs: Vec<PushReportRef>,
363}
364
365impl PushStatusReport {
366 pub fn had_errors(&self) -> bool {
368 self.refs.iter().any(PushReportRef::had_error)
369 }
370
371 pub fn refs_pushed(&self) -> bool {
374 self.refs.iter().any(|reference| {
375 reference.old_id != reference.new_id && matches!(reference.status, PushRefStatus::Ok)
376 })
377 }
378}
379
380#[derive(Clone, Copy)]
382pub struct PushRequest<'a> {
383 pub git_dir: &'a Path,
385 pub common_git_dir: &'a Path,
387 pub format: ObjectFormat,
389 pub config: &'a GitConfig,
391 pub remote: &'a str,
393 pub destination: &'a PushDestination,
395 pub refspecs: &'a [String],
397 pub options: &'a PushOptions,
399}
400
401#[derive(Clone, Copy)]
403pub struct PushActionRequest<'a> {
404 pub git_dir: &'a Path,
406 pub common_git_dir: &'a Path,
408 pub format: ObjectFormat,
410 pub config: &'a GitConfig,
412 pub remote: &'a str,
414 pub destination: &'a PushDestination,
416 pub plan: &'a PushActionPlan,
418}
419
420pub struct PushServices<'a> {
422 pub credentials: &'a mut dyn CredentialProvider,
424 pub progress: &'a mut dyn ProgressSink,
426}
427
428pub struct PushPlan {
431 pub commands: Vec<ReceivePackCommand>,
433 pub preflight_rejections: Vec<(ReceivePackCommand, PushRefStatus)>,
435 execution: PushExecution,
436}
437
438enum PushExecution {
439 Noop,
440 #[cfg(feature = "http")]
441 Http {
442 http_batch: crate::http::HttpOperationBatch,
443 remote_url: RemoteUrl,
444 features: ReceivePackFeatures,
445 advertisements: Vec<RefAdvertisement>,
446 pack_objects: Vec<ObjectId>,
447 },
448 Ssh(crate::ssh::SshPushPlan),
449 Git(crate::git::GitPushPlan),
450 Local {
451 remote_git_dir: PathBuf,
452 remote_common_git_dir: PathBuf,
453 remote_refs: Vec<RefAdvertisement>,
454 command_forces: Vec<(ReceivePackCommand, bool)>,
455 pack_objects: Vec<ObjectId>,
456 },
457}
458
459pub fn push(request: PushRequest<'_>, mut services: PushServices<'_>) -> Result<PushOutcome> {
474 let plan = plan_push(request, &mut services)?;
475 execute_push_plan(request, &mut services, plan)
476}
477
478pub fn push_actions(
480 request: PushActionRequest<'_>,
481 mut services: PushServices<'_>,
482) -> Result<PushOutcome> {
483 let plan = plan_push_actions(request, &mut services)?;
484 execute_push_action_plan(request, &mut services, plan)
485}
486
487pub fn plan_push(request: PushRequest<'_>, services: &mut PushServices<'_>) -> Result<PushPlan> {
490 let _ = &mut services.progress;
495 crate::protocol::check_transport_allowed(
496 scheme_for_push_destination(request.destination),
497 Some(request.config),
498 None,
499 )
500 .map_err(crate::protocol::transport_policy_git_error)?;
501 match request.destination {
502 #[cfg(feature = "http")]
503 PushDestination::Http(remote_url) => plan_push_http(PushHttpRequest {
504 git_dir: request.git_dir,
505 common_git_dir: request.common_git_dir,
506 format: request.format,
507 config: request.config,
508 remote_url,
509 refspecs: request.refspecs,
510 options: request.options,
511 credentials: services.credentials,
512 }),
513 #[cfg(not(feature = "http"))]
514 PushDestination::Http(_) => Err(GitError::Unsupported(
515 "HTTP transport is not enabled in this build".into(),
516 )),
517 PushDestination::Ssh(remote_url) => {
518 let plan = crate::ssh::plan_push_ssh(crate::ssh::SshPushRequest {
519 git_dir: request.git_dir,
520 common_git_dir: request.common_git_dir,
521 format: request.format,
522 remote: remote_url,
523 refspecs: request.refspecs,
524 force: request.options.force,
525 command_options: crate::ssh::ssh_transport_options_from_config(request.config),
526 })?;
527 let commands = plan.commands.clone();
528 let execution = if commands.is_empty() {
529 PushExecution::Noop
530 } else {
531 PushExecution::Ssh(plan)
532 };
533 Ok(PushPlan {
534 commands,
535 preflight_rejections: Vec::new(),
536 execution,
537 })
538 }
539 PushDestination::Git(remote_url) => {
540 let plan = crate::git::plan_push_git(crate::git::GitPushRequest {
541 git_dir: request.git_dir,
542 common_git_dir: request.common_git_dir,
543 format: request.format,
544 remote: remote_url,
545 config: Some(request.config),
546 refspecs: request.refspecs,
547 force: request.options.force,
548 })?;
549 let commands = plan.commands.clone();
550 let execution = if commands.is_empty() {
551 PushExecution::Noop
552 } else {
553 PushExecution::Git(plan)
554 };
555 Ok(PushPlan {
556 commands,
557 preflight_rejections: Vec::new(),
558 execution,
559 })
560 }
561 PushDestination::Local {
562 git_dir: remote_git_dir,
563 common_git_dir: remote_common_git_dir,
564 } => plan_push_local(PushLocalRequest {
565 git_dir: request.git_dir,
566 common_git_dir: request.common_git_dir,
567 format: request.format,
568 remote: request.remote,
569 remote_git_dir,
570 remote_common_git_dir,
571 refspecs: request.refspecs,
572 options: request.options,
573 }),
574 }
575}
576
577pub fn plan_push_actions(
580 request: PushActionRequest<'_>,
581 services: &mut PushServices<'_>,
582) -> Result<PushPlan> {
583 let _ = &mut services.progress;
584 crate::protocol::check_transport_allowed(
585 scheme_for_push_destination(request.destination),
586 Some(request.config),
587 None,
588 )
589 .map_err(crate::protocol::transport_policy_git_error)?;
590 let commands = receive_pack_commands_from_action_plan(request.format, request.plan)?;
591 let command_forces = commands
592 .iter()
593 .cloned()
594 .zip(request.plan.commands.iter())
595 .map(|(command, planned)| (command, request.plan.options.force || planned.force))
596 .collect::<Vec<_>>();
597 match request.destination {
598 #[cfg(feature = "http")]
599 PushDestination::Http(remote_url) => {
600 let http_batch = crate::http::HttpOperationBatch::new();
601 let discovered = crate::http::http_service_advertisements(
602 http_batch.client(),
603 remote_url,
604 request.format,
605 GitService::ReceivePack,
606 services.credentials,
607 Some(request.config),
608 )?;
609 let advertisement_set = discovered.set;
610 let features = advertised_receive_pack_features(&advertisement_set.refs)?;
611 verify_remote_object_format(&features, request.format)?;
612 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
613 reject_non_fast_forward_pushes(
614 request.common_git_dir,
615 &local_db,
616 request.format,
617 &command_forces,
618 )?;
619 let execution = if commands.is_empty() {
620 PushExecution::Noop
621 } else {
622 PushExecution::Http {
623 http_batch,
624 remote_url: remote_url.clone(),
625 features,
626 advertisements: advertisement_set.refs,
627 pack_objects: request.plan.pack_objects.clone(),
628 }
629 };
630 Ok(PushPlan {
631 commands,
632 preflight_rejections: Vec::new(),
633 execution,
634 })
635 }
636 #[cfg(not(feature = "http"))]
637 PushDestination::Http(_) => Err(GitError::Unsupported(
638 "HTTP transport is not enabled in this build".into(),
639 )),
640 PushDestination::Ssh(remote_url) => {
641 let plan = crate::ssh::plan_push_ssh_commands(crate::ssh::SshPushCommandsRequest {
642 common_git_dir: request.common_git_dir,
643 format: request.format,
644 remote: remote_url,
645 command_forces: command_forces.clone(),
646 pack_objects: request.plan.pack_objects.clone(),
647 })?;
648 let commands = plan.commands.clone();
649 let execution = if commands.is_empty() {
650 PushExecution::Noop
651 } else {
652 PushExecution::Ssh(plan)
653 };
654 Ok(PushPlan {
655 commands,
656 preflight_rejections: Vec::new(),
657 execution,
658 })
659 }
660 PushDestination::Git(remote_url) => {
661 let plan = crate::git::plan_push_git_commands(crate::git::GitPushCommandsRequest {
662 common_git_dir: request.common_git_dir,
663 format: request.format,
664 remote: remote_url,
665 config: Some(request.config),
666 command_forces: command_forces.clone(),
667 pack_objects: request.plan.pack_objects.clone(),
668 })?;
669 let commands = plan.commands.clone();
670 let execution = if commands.is_empty() {
671 PushExecution::Noop
672 } else {
673 PushExecution::Git(plan)
674 };
675 Ok(PushPlan {
676 commands,
677 preflight_rejections: Vec::new(),
678 execution,
679 })
680 }
681 PushDestination::Local {
682 git_dir: remote_git_dir,
683 common_git_dir: remote_common_git_dir,
684 } => {
685 let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
686 if remote_format != request.format {
687 return Err(GitError::InvalidObjectId(format!(
688 "remote repository uses {}, local repository uses {}",
689 remote_format.name(),
690 request.format.name()
691 )));
692 }
693 let remote_refs =
694 crate::local::local_fetch_advertisements(remote_git_dir, request.format)?;
695 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
696 reject_non_fast_forward_pushes(
697 request.common_git_dir,
698 &local_db,
699 request.format,
700 &command_forces,
701 )?;
702 let execution = if commands.is_empty() {
703 PushExecution::Noop
704 } else {
705 PushExecution::Local {
706 remote_git_dir: remote_git_dir.to_path_buf(),
707 remote_common_git_dir: remote_common_git_dir.to_path_buf(),
708 remote_refs,
709 command_forces,
710 pack_objects: request.plan.pack_objects.clone(),
711 }
712 };
713 Ok(PushPlan {
714 commands,
715 preflight_rejections: Vec::new(),
716 execution,
717 })
718 }
719 }
720}
721
722fn scheme_for_push_destination(destination: &PushDestination) -> &'static str {
723 match destination {
724 PushDestination::Http(remote) => crate::protocol::transport_scheme_for_remote(remote),
725 PushDestination::Ssh(remote) => crate::protocol::transport_scheme_for_remote(remote),
726 PushDestination::Git(remote) => crate::protocol::transport_scheme_for_remote(remote),
727 PushDestination::Local { .. } => "file",
728 }
729}
730
731pub fn execute_push_plan(
733 request: PushRequest<'_>,
734 services: &mut PushServices<'_>,
735 plan: PushPlan,
736) -> Result<PushOutcome> {
737 let _ = (request.config, request.remote);
738 let _ = &mut services.progress;
739 if plan.commands.is_empty() {
740 return Ok(PushOutcome::default());
741 }
742 match plan.execution {
743 PushExecution::Noop => Ok(PushOutcome::default()),
744 #[cfg(feature = "http")]
745 PushExecution::Http {
746 http_batch,
747 remote_url,
748 features,
749 advertisements,
750 pack_objects,
751 } => execute_push_http(
752 request,
753 services.credentials,
754 http_batch,
755 plan.commands,
756 remote_url,
757 features,
758 advertisements,
759 pack_objects,
760 ),
761 PushExecution::Ssh(plan) => crate::ssh::execute_push_ssh_plan(request, plan),
762 PushExecution::Git(plan) => crate::git::execute_push_git_plan(request, plan),
763 PushExecution::Local {
764 remote_git_dir,
765 remote_common_git_dir,
766 remote_refs,
767 command_forces,
768 pack_objects,
769 } => execute_push_local(
770 request,
771 plan.commands,
772 remote_git_dir,
773 remote_common_git_dir,
774 remote_refs,
775 command_forces,
776 pack_objects,
777 ),
778 }
779}
780
781pub fn execute_push_action_plan(
783 request: PushActionRequest<'_>,
784 services: &mut PushServices<'_>,
785 plan: PushPlan,
786) -> Result<PushOutcome> {
787 let refspecs: &[String] = &[];
788 execute_push_plan(
789 PushRequest {
790 git_dir: request.git_dir,
791 common_git_dir: request.common_git_dir,
792 format: request.format,
793 config: request.config,
794 remote: request.remote,
795 destination: request.destination,
796 refspecs,
797 options: &request.plan.options,
798 },
799 services,
800 plan,
801 )
802}
803
804#[cfg(feature = "http")]
807struct PushHttpRequest<'a> {
808 git_dir: &'a Path,
809 common_git_dir: &'a Path,
810 format: ObjectFormat,
811 config: &'a GitConfig,
812 remote_url: &'a RemoteUrl,
813 refspecs: &'a [String],
814 options: &'a PushOptions,
815 credentials: &'a mut dyn CredentialProvider,
816}
817
818#[cfg(feature = "http")]
819fn plan_push_http(request: PushHttpRequest<'_>) -> Result<PushPlan> {
820 let PushHttpRequest {
821 git_dir,
822 common_git_dir,
823 format,
824 config,
825 remote_url,
826 refspecs,
827 options,
828 credentials,
829 } = request;
830 let http_batch = crate::http::HttpOperationBatch::new();
831 let discovered = crate::http::http_service_advertisements(
832 http_batch.client(),
833 remote_url,
834 format,
835 GitService::ReceivePack,
836 credentials,
837 Some(config),
838 )?;
839 let advertisement_set = discovered.set;
840 let features = advertised_receive_pack_features(&advertisement_set.refs)?;
841 verify_remote_object_format(&features, format)?;
842 if options.atomic && !features.atomic {
843 return Err(GitError::InvalidFormat(
844 "receive-pack feature 'atomic' was not advertised".into(),
845 ));
846 }
847 if !options.push_options.is_empty() && !features.push_options {
848 return Err(GitError::InvalidFormat(
849 "receive-pack feature 'push-options' was not advertised".into(),
850 ));
851 }
852
853 let local_store = FileRefStore::new(git_dir, format);
854 let mut local_refs = local_push_source_refs(&local_store, format)?;
855 add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
856 let command_forces = plan_push_command_forces(
857 format,
858 &local_refs,
859 &advertisement_set.refs,
860 refspecs,
861 options.force,
862 )?;
863 let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
864 let mut accepted = Vec::new();
865 let mut preflight_rejections = Vec::new();
866 for (command, force) in command_forces {
867 if push_command_is_non_fast_forward(common_git_dir, &local_db, format, &command, force)? {
868 preflight_rejections.push((command, PushRefStatus::RejectNonFastForward));
869 } else {
870 accepted.push((command, force));
871 }
872 }
873 if options.atomic && !preflight_rejections.is_empty() {
874 preflight_rejections.extend(
875 accepted
876 .drain(..)
877 .map(|(command, _)| (command, PushRefStatus::AtomicPushFailed)),
878 );
879 }
880 let commands = commands_from_forces(&accepted);
881 let execution = if commands.is_empty() {
882 PushExecution::Noop
883 } else {
884 PushExecution::Http {
885 http_batch,
886 remote_url: remote_url.clone(),
887 features,
888 advertisements: advertisement_set.refs,
889 pack_objects: Vec::new(),
890 }
891 };
892 Ok(PushPlan {
893 commands,
894 preflight_rejections,
895 execution,
896 })
897}
898
899#[cfg(feature = "http")]
900#[allow(clippy::too_many_arguments)]
901fn execute_push_http(
902 request: PushRequest<'_>,
903 credentials: &mut dyn CredentialProvider,
904 http_batch: crate::http::HttpOperationBatch,
905 commands: Vec<ReceivePackCommand>,
906 remote_url: RemoteUrl,
907 features: ReceivePackFeatures,
908 advertisements: Vec<RefAdvertisement>,
909 pack_objects: Vec<ObjectId>,
910) -> Result<PushOutcome> {
911 let client = http_batch.client();
912 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
913 let pack_request = PushPackRequest {
914 local_db: &local_db,
915 format: request.format,
916 commands: &commands,
917 pack_objects: &pack_objects,
918 remote_advertisements: &advertisements,
919 features: &features,
920 options: receive_pack_push_options(
921 &features,
922 request.format,
923 request.options.quiet,
924 request.options.atomic,
925 &request.options.push_options,
926 ),
927 thin: request.options.thin.wants_thin(),
928 };
929 let url = http_smart_rpc_url(&remote_url, GitService::ReceivePack)?;
930 let content_type = smart_http_rpc_request_content_type(GitService::ReceivePack)?;
931 let post_buffer = http_post_buffer(request.config);
932 let git_protocol = crate::http::http_git_protocol_header_value_for_service(
933 Some(request.config),
934 GitService::ReceivePack,
935 )?;
936 let mut response = crate::http::http_send_with_auth(&remote_url, credentials, |auth| {
937 let headers = crate::http::http_request_headers(auth, git_protocol.as_deref());
938 send_receive_pack_body(
939 client,
940 &url,
941 &content_type,
942 &headers,
943 &pack_request,
944 post_buffer,
945 )
946 })?;
947 crate::http::http_check_status(&response, &url)?;
948 crate::http::http_validate_content_type(
949 &response,
950 &smart_http_rpc_result_content_type(GitService::ReceivePack)?,
951 )?;
952
953 let mut remote_progress = Vec::new();
954 let report = if features.report_status || features.report_status_v2 {
955 let report = read_receive_pack_push_report_with_progress(
956 request.format,
957 &mut response.body,
958 features.report_status_v2,
959 features.side_band_64k,
960 Some(&mut remote_progress),
961 )?;
962 validate_receive_pack_unpack(&report)?;
963 Some(report)
964 } else {
965 let mut sink = Vec::new();
966 response.body.read_to_end(&mut sink)?;
967 None
968 };
969 Ok(PushOutcome {
970 commands,
971 report,
972 remote_progress,
973 })
974}
975
976#[cfg(feature = "http")]
982fn http_post_buffer(config: &GitConfig) -> usize {
983 const DEFAULT_POST_BUFFER: usize = 1 << 20;
984 config
985 .get("http", None, "postBuffer")
986 .and_then(parse_post_buffer)
987 .filter(|bytes| *bytes > 0)
988 .unwrap_or(DEFAULT_POST_BUFFER)
989}
990
991#[cfg(feature = "http")]
994fn parse_post_buffer(raw: &str) -> Option<usize> {
995 let raw = raw.trim();
996 let (digits, multiplier) = match raw.as_bytes().last() {
997 Some(b'k' | b'K') => (&raw[..raw.len() - 1], 1024usize),
998 Some(b'm' | b'M') => (&raw[..raw.len() - 1], 1024 * 1024),
999 Some(b'g' | b'G') => (&raw[..raw.len() - 1], 1024 * 1024 * 1024),
1000 _ => (raw, 1),
1001 };
1002 digits
1003 .trim()
1004 .parse::<usize>()
1005 .ok()
1006 .and_then(|value| value.checked_mul(multiplier))
1007}
1008
1009#[cfg(feature = "http")]
1015fn send_receive_pack_body(
1016 client: &dyn HttpClient,
1017 url: &str,
1018 content_type: &str,
1019 headers: &[(&str, &str)],
1020 pack_request: &PushPackRequest<'_>,
1021 post_buffer: usize,
1022) -> Result<HttpResponse> {
1023 std::thread::scope(|scope| {
1024 let (mut reader, writer) = std::io::pipe().map_err(|err| GitError::Io(err.to_string()))?;
1025 let generator = scope.spawn(move || -> Result<()> {
1026 let mut writer = writer;
1029 write_receive_pack_body(pack_request, &mut writer)
1030 });
1031
1032 let mut probe = Vec::new();
1035 read_up_to(&mut reader, post_buffer.saturating_add(1), &mut probe)?;
1036
1037 if probe.len() <= post_buffer {
1038 join_pack_generator(generator)?;
1042 client.post(url, content_type, headers, &probe)
1043 } else {
1044 let response = {
1049 let mut body = std::io::Cursor::new(probe).chain(reader);
1050 client.post_reader(url, content_type, headers, &mut body)
1051 };
1052 let generation = join_pack_generator(generator);
1053 match response {
1054 Ok(response) => Ok(response),
1057 Err(transport) => match generation {
1058 Err(generation) => Err(generation),
1059 Ok(()) => Err(transport),
1060 },
1061 }
1062 }
1063 })
1064}
1065
1066#[cfg(feature = "http")]
1069fn join_pack_generator(handle: std::thread::ScopedJoinHandle<'_, Result<()>>) -> Result<()> {
1070 match handle.join() {
1071 Ok(result) => result,
1072 Err(_) => Err(GitError::Io(
1073 "receive-pack body generator thread panicked".to_string(),
1074 )),
1075 }
1076}
1077
1078#[cfg(feature = "http")]
1080fn read_up_to(reader: &mut impl Read, cap: usize, out: &mut Vec<u8>) -> Result<()> {
1081 let mut chunk = [0u8; 8192];
1082 while out.len() < cap {
1083 let want = (cap - out.len()).min(chunk.len());
1084 let read = reader
1085 .read(&mut chunk[..want])
1086 .map_err(|err| GitError::Io(err.to_string()))?;
1087 if read == 0 {
1088 break;
1089 }
1090 out.extend_from_slice(&chunk[..read]);
1091 }
1092 Ok(())
1093}
1094
1095struct PushLocalRequest<'a> {
1099 git_dir: &'a Path,
1100 common_git_dir: &'a Path,
1101 format: ObjectFormat,
1102 remote: &'a str,
1103 remote_git_dir: &'a Path,
1104 remote_common_git_dir: &'a Path,
1105 refspecs: &'a [String],
1106 options: &'a PushOptions,
1107}
1108
1109fn plan_push_local(request: PushLocalRequest<'_>) -> Result<PushPlan> {
1110 let PushLocalRequest {
1111 git_dir,
1112 common_git_dir,
1113 format,
1114 remote,
1115 remote_git_dir,
1116 remote_common_git_dir,
1117 refspecs,
1118 options,
1119 } = request;
1120 let _ = remote;
1121 let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
1122 if remote_format != format {
1123 return Err(GitError::InvalidObjectId(format!(
1124 "remote repository uses {}, local repository uses {}",
1125 remote_format.name(),
1126 format.name()
1127 )));
1128 }
1129
1130 let local_store = FileRefStore::new(git_dir, format);
1131 let mut local_refs = local_push_source_refs(&local_store, format)?;
1132 add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
1133 let remote_refs = crate::local::local_fetch_advertisements(remote_git_dir, format)?;
1134 let command_forces =
1135 plan_push_command_forces(format, &local_refs, &remote_refs, refspecs, options.force)?;
1136 let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
1137 reject_non_fast_forward_pushes(common_git_dir, &local_db, format, &command_forces)?;
1138 let commands = commands_from_forces(&command_forces);
1139 let execution = if commands.is_empty() {
1140 PushExecution::Noop
1141 } else {
1142 PushExecution::Local {
1143 remote_git_dir: remote_git_dir.to_path_buf(),
1144 remote_common_git_dir: remote_common_git_dir.to_path_buf(),
1145 remote_refs,
1146 command_forces,
1147 pack_objects: Vec::new(),
1148 }
1149 };
1150 Ok(PushPlan {
1151 commands,
1152 preflight_rejections: Vec::new(),
1153 execution,
1154 })
1155}
1156
1157fn execute_push_local(
1158 request: PushRequest<'_>,
1159 commands: Vec<ReceivePackCommand>,
1160 remote_git_dir: PathBuf,
1161 remote_common_git_dir: PathBuf,
1162 remote_refs: Vec<RefAdvertisement>,
1163 _command_forces: Vec<(ReceivePackCommand, bool)>,
1164 pack_objects: Vec<ObjectId>,
1165) -> Result<PushOutcome> {
1166 let remote_excluded_tips =
1167 remote_excluded_tip_roots(&remote_git_dir, request.format, &remote_refs)?;
1168 let starts = push_pack_roots(&commands, &pack_objects);
1169 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
1170 let remote_config = sley_config::read_repo_config(&remote_git_dir, None).unwrap_or_default();
1171 let remote_excluded = collect_remote_reachable_exclusions(
1172 &remote_common_git_dir,
1173 request.format,
1174 remote_excluded_tips,
1175 &remote_config,
1176 )?;
1177
1178 let report = if push_local_uses_receive_pack_server(&remote_config, &remote_git_dir, &commands)
1179 {
1180 local_push_via_receive_pack_server(LocalPushViaReceivePackRequest {
1181 remote_git_dir: &remote_git_dir,
1182 source_common_git_dir: request.common_git_dir,
1183 format: request.format,
1184 commands: &commands,
1185 push_options: &[],
1186 atomic: false,
1187 quiet: request.options.quiet,
1188 remote_advertisements: &remote_refs,
1189 remote_stderr: None,
1190 })?
1191 } else {
1192 let receive_request = ReceivePackPushRequest {
1193 commands: ReceivePackRequest {
1194 shallow: Vec::new(),
1195 commands: commands.clone(),
1196 capabilities: Vec::new(),
1197 },
1198 push_options: None,
1199 packfile: if starts.is_empty() {
1200 Vec::new()
1201 } else {
1202 b"PACK".to_vec()
1203 },
1204 };
1205 ReceivePackPushReport::V1(
1206 crate::local::receive_pack_reachable_pack_into_local_repository(
1207 &remote_git_dir,
1208 request.format,
1209 &receive_request,
1210 &local_db,
1211 starts,
1212 remote_excluded,
1213 )?,
1214 )
1215 };
1216 validate_receive_pack_unpack(&report)?;
1217 Ok(PushOutcome {
1218 commands,
1219 report: Some(report),
1220 remote_progress: Vec::new(),
1221 })
1222}
1223
1224pub struct PushQuarantine {
1227 object_dir: PathBuf,
1228}
1229
1230impl PushQuarantine {
1231 pub fn object_dir(&self) -> &Path {
1232 &self.object_dir
1233 }
1234}
1235
1236impl Drop for PushQuarantine {
1237 fn drop(&mut self) {
1238 let _ = fs::remove_dir_all(&self.object_dir);
1239 }
1240}
1241
1242pub fn stage_local_push_quarantine(
1243 remote_git_dir: &Path,
1244 remote_common_git_dir: &Path,
1245 format: ObjectFormat,
1246 source_db: &FileObjectDatabase,
1247 commands: &[ReceivePackCommand],
1248) -> Result<Option<PushQuarantine>> {
1249 let starts = push_pack_roots(commands, &[]);
1250 if starts.is_empty() {
1251 return Ok(None);
1252 }
1253 let remote_refs = crate::local::local_fetch_advertisements(remote_git_dir, format)?;
1254 let remote_excluded_tips = remote_excluded_tip_roots(remote_git_dir, format, &remote_refs)?;
1255 let remote_config = sley_config::read_repo_config(remote_git_dir, None).unwrap_or_default();
1256 let remote_excluded = collect_remote_reachable_exclusions(
1257 remote_common_git_dir,
1258 format,
1259 remote_excluded_tips,
1260 &remote_config,
1261 )?;
1262 let object_dir = create_push_quarantine_object_dir(remote_common_git_dir)?;
1263 let quarantine_db = FileObjectDatabase::new(object_dir.clone(), format);
1264 let installed = match build_and_install_reachable_pack(
1265 source_db,
1266 &quarantine_db,
1267 format,
1268 starts,
1269 &remote_excluded,
1270 RawPackInstallOptions {
1271 promisor: false,
1272 ..Default::default()
1273 },
1274 ) {
1275 Ok(installed) => installed,
1276 Err(err) => {
1277 let _ = fs::remove_dir_all(&object_dir);
1278 return Err(err);
1279 }
1280 };
1281 if installed.is_none() {
1282 let _ = fs::remove_dir_all(&object_dir);
1283 return Ok(None);
1284 }
1285 Ok(Some(PushQuarantine { object_dir }))
1286}
1287
1288fn collect_remote_reachable_exclusions<I>(
1296 remote_common_git_dir: &Path,
1297 format: ObjectFormat,
1298 starts: I,
1299 remote_config: &GitConfig,
1300) -> Result<std::collections::HashSet<ObjectId>>
1301where
1302 I: IntoIterator<Item = ObjectId>,
1303{
1304 let remote_has_promisor = crate::promisor::config_has_promisor_remote(remote_config);
1305 let remote_db = FileObjectDatabase::from_git_dir(remote_common_git_dir, format)
1306 .with_promisor_remote_present(remote_has_promisor);
1307 if remote_has_promisor {
1308 collect_reachable_object_ids_tolerating_promised_missing(&remote_db, format, starts)
1309 } else {
1310 collect_reachable_object_ids(&remote_db, format, starts)
1311 }
1312}
1313
1314fn create_push_quarantine_object_dir(remote_common_git_dir: &Path) -> Result<PathBuf> {
1315 let objects_dir = remote_common_git_dir.join("objects");
1316 fs::create_dir_all(&objects_dir)?;
1317 let nanos = SystemTime::now()
1318 .duration_since(UNIX_EPOCH)
1319 .map(|duration| duration.as_nanos())
1320 .unwrap_or(0);
1321 for attempt in 0..100 {
1322 let object_dir = objects_dir.join(format!(
1323 "tmp_objdir-incoming-{}-{nanos}-{attempt}",
1324 std::process::id()
1325 ));
1326 match fs::create_dir(&object_dir) {
1327 Ok(()) => {
1328 fs::create_dir_all(object_dir.join("pack"))?;
1329 fs::create_dir_all(object_dir.join("info"))?;
1330 return Ok(object_dir);
1331 }
1332 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
1333 Err(err) => return Err(GitError::Io(err.to_string())),
1334 }
1335 }
1336 Err(GitError::Io(
1337 "could not create push quarantine object directory".into(),
1338 ))
1339}
1340
1341fn remote_excluded_tip_roots(
1342 remote_git_dir: &Path,
1343 format: ObjectFormat,
1344 remote_refs: &[RefAdvertisement],
1345) -> Result<Vec<ObjectId>> {
1346 let mut tips = remote_refs
1347 .iter()
1348 .map(|reference| reference.oid)
1349 .collect::<Vec<_>>();
1350 append_remote_alternate_ref_tips(remote_git_dir, format, &mut tips)?;
1351 Ok(tips)
1352}
1353
1354fn append_remote_alternate_ref_tips(
1355 remote_git_dir: &Path,
1356 format: ObjectFormat,
1357 tips: &mut Vec<ObjectId>,
1358) -> Result<()> {
1359 let alternates = remote_git_dir.join("objects/info/alternates");
1360 let Ok(text) = fs::read_to_string(alternates) else {
1361 return Ok(());
1362 };
1363 for raw in text.lines() {
1364 let line = raw.trim();
1365 if line.is_empty() || line.starts_with('#') {
1366 continue;
1367 }
1368 let objects_dir = if Path::new(line).is_absolute() {
1369 PathBuf::from(line)
1370 } else {
1371 remote_git_dir.join("objects").join(line)
1372 };
1373 let Some(alternate_git_dir) = objects_dir.parent() else {
1374 continue;
1375 };
1376 let store = FileRefStore::new(alternate_git_dir, format);
1377 let refs = match store.list_refs() {
1378 Ok(refs) => refs,
1379 Err(_) => continue,
1380 };
1381 for reference in refs {
1382 let Some((oid, _)) = resolve_for_each_ref_target(&store, &reference)? else {
1383 continue;
1384 };
1385 if !tips.contains(&oid) {
1386 tips.push(oid);
1387 }
1388 }
1389 }
1390 Ok(())
1391}
1392
1393pub struct PushReportRequest<'a> {
1395 pub git_dir: &'a Path,
1397 pub common_git_dir: &'a Path,
1399 pub format: ObjectFormat,
1401 pub remote_git_dir: &'a Path,
1403 pub remote_common_git_dir: &'a Path,
1405 pub refspecs: &'a [String],
1407 pub force: bool,
1409 pub atomic: bool,
1411 pub dry_run: bool,
1413 pub force_with_lease: &'a [(String, Option<ObjectId>)],
1416 pub force_with_lease_default: bool,
1421 pub force_if_includes: bool,
1424 pub receive_config_overrides: &'a [(String, String)],
1427 pub push_options: &'a [String],
1429 pub remote_stderr: Option<&'a mut Vec<u8>>,
1431 pub quiet: bool,
1433}
1434
1435pub fn push_local_with_report(
1443 request: PushReportRequest<'_>,
1444 config: &GitConfig,
1445) -> Result<PushStatusReport> {
1446 let objects = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
1447 push_local_with_report_and_objects(request, config, &objects)
1448}
1449
1450pub fn push_local_with_report_and_objects(
1457 request: PushReportRequest<'_>,
1458 _config: &GitConfig,
1459 objects: &FileObjectDatabase,
1460) -> Result<PushStatusReport> {
1461 let format = request.format;
1462 let remote_format = crate::object_format_for_git_dir(request.remote_common_git_dir)?;
1463 if remote_format != format {
1464 return Err(GitError::InvalidObjectId(format!(
1465 "remote repository uses {}, local repository uses {}",
1466 remote_format.name(),
1467 format.name()
1468 )));
1469 }
1470 let local_store = FileRefStore::new(request.git_dir, format);
1471 let mut local_refs = local_push_source_refs(&local_store, format)?;
1472 add_revision_push_sources_with_reader(
1473 request.git_dir,
1474 format,
1475 objects,
1476 request.refspecs,
1477 &mut local_refs,
1478 );
1479 let remote_refs = crate::local::local_fetch_advertisements(request.remote_git_dir, format)?;
1480 let planned = plan_push_command_sources(
1481 format,
1482 &local_refs,
1483 &remote_refs,
1484 request.refspecs,
1485 request.force,
1486 )?;
1487 let source_db = objects;
1488 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, format);
1491 let remote_config =
1492 sley_config::read_repo_config(request.remote_git_dir, None).unwrap_or_default();
1493 let _ = sley_fsck::FsckPolicy::from_config(
1497 &remote_config,
1498 sley_fsck::FsckConfigKind::Receive,
1499 format,
1500 request.remote_git_dir,
1501 false,
1502 )?;
1503
1504 let mut refs: Vec<PushReportRef> = Vec::new();
1507 for plan in &planned {
1508 let status = classify_push_command(
1509 source_db,
1510 format,
1511 plan,
1512 &request,
1513 &remote_config,
1514 request.remote_git_dir,
1515 )?;
1516 let stale_lease_overridden = plan.force && lease_expectation_mismatch(&request, plan);
1519 let forced = matches!(status, PushRefStatus::Ok)
1520 && !plan.command.old_id.is_null()
1521 && !plan.command.new_id.is_null()
1522 && (stale_lease_overridden
1523 || if plan.command.name.starts_with("refs/heads/") {
1524 !is_fast_forward(
1525 request.common_git_dir,
1526 source_db,
1527 format,
1528 &plan.command.old_id,
1529 &plan.command.new_id,
1530 )?
1531 } else {
1532 plan.force
1533 });
1534 refs.push(PushReportRef {
1535 src: plan.source.clone(),
1536 dst: plan.command.name.clone(),
1537 old_id: plan.command.old_id,
1538 new_id: plan.command.new_id,
1539 forced,
1540 status,
1541 reports: Vec::new(),
1542 });
1543 }
1544
1545 let any_local_reject = refs.iter().any(|reference| {
1546 matches!(
1547 reference.status,
1548 PushRefStatus::RejectNonFastForward
1549 | PushRefStatus::RejectFetchFirst
1550 | PushRefStatus::RejectStale
1551 | PushRefStatus::RejectRemoteUpdated
1552 | PushRefStatus::RejectAlreadyExists
1553 )
1554 });
1555
1556 if request.atomic && any_local_reject {
1560 for reference in &mut refs {
1561 if matches!(reference.status, PushRefStatus::Ok) {
1562 reference.status = PushRefStatus::AtomicPushFailed;
1563 }
1564 }
1565 return Ok(PushStatusReport { refs });
1566 }
1567
1568 if request.dry_run {
1569 return Ok(PushStatusReport { refs });
1570 }
1571
1572 let send: Vec<ReceivePackCommand> = refs
1574 .iter()
1575 .filter(|reference| {
1576 matches!(reference.status, PushRefStatus::Ok) && reference.old_id != reference.new_id
1577 })
1578 .map(|reference| ReceivePackCommand {
1579 old_id: reference.old_id,
1580 new_id: reference.new_id,
1581 name: reference.dst.clone(),
1582 })
1583 .collect();
1584
1585 if !send.is_empty() {
1586 let remote_excluded_tips =
1587 remote_excluded_tip_roots(request.remote_git_dir, format, &remote_refs)?;
1588 let pack_objects: Vec<ObjectId> = Vec::new();
1589 let starts = push_pack_roots(&send, &pack_objects);
1590 let remote_excluded = collect_remote_reachable_exclusions(
1591 request.remote_common_git_dir,
1592 format,
1593 remote_excluded_tips,
1594 &remote_config,
1595 )?;
1596 let report =
1597 if push_local_uses_receive_pack_server(&remote_config, request.remote_git_dir, &send) {
1598 local_push_via_receive_pack_server(LocalPushViaReceivePackRequest {
1599 remote_git_dir: request.remote_git_dir,
1600 source_common_git_dir: request.common_git_dir,
1601 format,
1602 commands: &send,
1603 push_options: request.push_options,
1604 atomic: request.atomic,
1605 quiet: request.quiet,
1606 remote_advertisements: &remote_refs,
1607 remote_stderr: request.remote_stderr,
1608 })?
1609 } else {
1610 let receive_request = ReceivePackPushRequest {
1611 commands: ReceivePackRequest {
1612 shallow: Vec::new(),
1613 commands: send.clone(),
1614 capabilities: Vec::new(),
1615 },
1616 push_options: None,
1617 packfile: if starts.is_empty() {
1618 Vec::new()
1619 } else {
1620 b"PACK".to_vec()
1621 },
1622 };
1623 ReceivePackPushReport::V1(
1624 crate::local::receive_pack_reachable_pack_into_local_repository(
1625 request.remote_git_dir,
1626 format,
1627 &receive_request,
1628 &local_db,
1629 starts,
1630 remote_excluded,
1631 )?,
1632 )
1633 };
1634 apply_receive_pack_report_to_push_refs(&mut refs, &report);
1635 }
1636
1637 Ok(PushStatusReport { refs })
1638}
1639
1640fn classify_push_command(
1644 local_db: &FileObjectDatabase,
1645 format: ObjectFormat,
1646 plan: &PlannedPushCommand,
1647 request: &PushReportRequest<'_>,
1648 config: &GitConfig,
1649 remote_git_dir: &Path,
1650) -> Result<PushRefStatus> {
1651 let command = &plan.command;
1652
1653 if receive_ref_is_hidden(config, request.receive_config_overrides, &command.name) {
1654 let reason = if command.new_id.is_null() {
1655 "deny deleting a hidden ref"
1656 } else {
1657 "deny updating a hidden ref"
1658 };
1659 return Ok(PushRefStatus::RemoteReject(reason.to_string()));
1660 }
1661
1662 if command.old_id == command.new_id && !command.new_id.is_null() {
1665 return Ok(PushRefStatus::UpToDate);
1666 }
1667
1668 if command.new_id.is_null() && !command.old_id.is_null() {
1669 if receive_config_bool(config, request.receive_config_overrides, "denydeletes")
1670 .unwrap_or(false)
1671 {
1672 return Ok(PushRefStatus::RemoteReject(
1673 "deletion prohibited".to_string(),
1674 ));
1675 }
1676 if receive_denies_current_branch_delete(format, command, config, request, remote_git_dir)? {
1677 return Ok(PushRefStatus::RemoteReject(
1678 "deletion of the current branch prohibited".to_string(),
1679 ));
1680 }
1681 }
1682
1683 if command.name.starts_with("refs/heads/") && !command.new_id.is_null() {
1684 let object = local_db.read_object(&command.new_id)?;
1685 if object.object_type != ObjectType::Commit {
1686 return Ok(PushRefStatus::RemoteReject(
1687 "invalid new value provided".to_string(),
1688 ));
1689 }
1690 }
1691
1692 if let Some((_, expected)) = request
1696 .force_with_lease
1697 .iter()
1698 .find(|(dst, _)| *dst == command.name)
1699 {
1700 let actual = if command.old_id.is_null() {
1701 None
1702 } else {
1703 Some(command.old_id)
1704 };
1705 if *expected != actual {
1706 if plan.force {
1707 return Ok(PushRefStatus::Ok);
1708 }
1709 return Ok(PushRefStatus::RejectStale);
1710 }
1711 if request.force_if_includes
1712 && !command.old_id.is_null()
1713 && (command.new_id.is_null()
1714 || !is_fast_forward(
1715 request.common_git_dir,
1716 local_db,
1717 format,
1718 &command.old_id,
1719 &command.new_id,
1720 )?)
1721 && force_if_includes_rejects(
1722 request.common_git_dir,
1723 local_db,
1724 format,
1725 request.git_dir,
1726 &command.name,
1727 &command.old_id,
1728 )?
1729 {
1730 if plan.force {
1731 return Ok(PushRefStatus::Ok);
1732 }
1733 return Ok(PushRefStatus::RejectRemoteUpdated);
1734 }
1735 return Ok(PushRefStatus::Ok);
1737 }
1738
1739 if command.name.starts_with("refs/heads/")
1740 && !command.old_id.is_null()
1741 && !command.new_id.is_null()
1742 && !is_fast_forward(
1743 request.common_git_dir,
1744 local_db,
1745 format,
1746 &command.old_id,
1747 &command.new_id,
1748 )?
1749 && receive_config_bool(
1750 config,
1751 request.receive_config_overrides,
1752 "denynonfastforwards",
1753 )
1754 .unwrap_or(false)
1755 {
1756 return Ok(PushRefStatus::RemoteReject(format!(
1757 "denying non-fast-forward {} (you should pull first)",
1758 command.name
1759 )));
1760 }
1761
1762 if !plan.force
1765 && command.name.starts_with("refs/tags/")
1766 && !command.old_id.is_null()
1767 && !command.new_id.is_null()
1768 {
1769 return Ok(PushRefStatus::RejectAlreadyExists);
1770 }
1771
1772 if !plan.force
1773 && command.name.starts_with("refs/heads/")
1774 && !command.old_id.is_null()
1775 && !command.new_id.is_null()
1776 {
1777 if !local_db.contains(&command.old_id)? {
1778 return Ok(PushRefStatus::RejectFetchFirst);
1779 }
1780 if !is_fast_forward(
1781 request.common_git_dir,
1782 local_db,
1783 format,
1784 &command.old_id,
1785 &command.new_id,
1786 )? {
1787 return Ok(PushRefStatus::RejectNonFastForward);
1788 }
1789 }
1790
1791 if !request.dry_run && receive_denies_current_branch(format, command, config, remote_git_dir)? {
1792 return Ok(PushRefStatus::RemoteReject(
1793 "branch is currently checked out".to_string(),
1794 ));
1795 }
1796
1797 Ok(PushRefStatus::Ok)
1798}
1799
1800fn receive_ref_is_hidden(
1801 config: &GitConfig,
1802 overrides: &[(String, String)],
1803 refname: &str,
1804) -> bool {
1805 let mut hide_refs = Vec::new();
1806 hide_refs.extend(hidden_ref_values(config, "transfer", None));
1807 hide_refs.extend(hidden_ref_values(config, "receive", None));
1808 hide_refs.extend(
1809 overrides
1810 .iter()
1811 .filter(|(key, _)| key.eq_ignore_ascii_case("hiderefs"))
1812 .map(|(_, value)| trim_hidden_ref_pattern(value)),
1813 );
1814 ref_is_hidden_by_patterns(refname, &hide_refs)
1815}
1816
1817fn hidden_ref_values(config: &GitConfig, section: &str, subsection: Option<&str>) -> Vec<String> {
1818 config
1819 .get_all(section, subsection, "hiderefs")
1820 .into_iter()
1821 .flatten()
1822 .map(trim_hidden_ref_pattern)
1823 .collect()
1824}
1825
1826fn trim_hidden_ref_pattern(value: &str) -> String {
1827 value.trim_end_matches('/').to_string()
1828}
1829
1830fn ref_is_hidden_by_patterns(refname: &str, patterns: &[String]) -> bool {
1831 for pattern in patterns.iter().rev() {
1832 let mut pattern = pattern.as_str();
1833 let negated = pattern.strip_prefix('!').is_some();
1834 if negated {
1835 pattern = &pattern[1..];
1836 }
1837 if let Some(rest) = pattern.strip_prefix('^') {
1838 pattern = rest;
1839 }
1840 if hidden_ref_pattern_matches(refname, pattern) {
1841 return !negated;
1842 }
1843 }
1844 false
1845}
1846
1847fn hidden_ref_pattern_matches(refname: &str, pattern: &str) -> bool {
1848 refname
1849 .strip_prefix(pattern)
1850 .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
1851}
1852
1853fn lease_expectation_mismatch(request: &PushReportRequest<'_>, plan: &PlannedPushCommand) -> bool {
1854 let command = &plan.command;
1855 let actual = if command.old_id.is_null() {
1856 None
1857 } else {
1858 Some(command.old_id)
1859 };
1860 request
1861 .force_with_lease
1862 .iter()
1863 .find(|(dst, _)| *dst == command.name)
1864 .is_some_and(|(_, expected)| *expected != actual)
1865}
1866
1867fn force_if_includes_rejects(
1868 common_git_dir: &Path,
1869 db: &FileObjectDatabase,
1870 format: ObjectFormat,
1871 git_dir: &Path,
1872 local_ref: &str,
1873 remote_old: &ObjectId,
1874) -> Result<bool> {
1875 let store = FileRefStore::new(git_dir, format);
1876 let mut candidates = Vec::new();
1877 match store.read_ref(local_ref)? {
1878 Some(RefTarget::Direct(oid)) => candidates.push(oid),
1879 Some(RefTarget::Symbolic(target)) => {
1880 if let Some(RefTarget::Direct(oid)) = store.read_ref(&target)? {
1881 candidates.push(oid);
1882 }
1883 }
1884 None => return Ok(false),
1885 }
1886 for entry in store.read_reflog(local_ref)? {
1887 if !entry.new_oid.is_null() {
1888 candidates.push(entry.new_oid);
1889 }
1890 }
1891 candidates.sort();
1892 candidates.dedup();
1893 for candidate in candidates {
1894 if candidate == *remote_old {
1895 return Ok(false);
1896 }
1897 if let Ok(ancestors) = sley_rev::ancestor_depths(common_git_dir, format, db, &candidate)
1898 && ancestors.contains_key(remote_old)
1899 {
1900 return Ok(false);
1901 }
1902 }
1903 Ok(true)
1904}
1905
1906fn receive_config_bool(
1907 config: &GitConfig,
1908 overrides: &[(String, String)],
1909 key: &str,
1910) -> Option<bool> {
1911 overrides
1912 .iter()
1913 .rev()
1914 .find(|(candidate, _)| candidate.eq_ignore_ascii_case(key))
1915 .and_then(|(_, value)| sley_config::parse_config_bool(value))
1916 .or_else(|| config.get_bool("receive", None, key))
1917}
1918
1919fn receive_denies_current_branch(
1920 format: ObjectFormat,
1921 command: &ReceivePackCommand,
1922 config: &GitConfig,
1923 remote_git_dir: &Path,
1924) -> Result<bool> {
1925 if command.new_id.is_null() {
1926 return Ok(false);
1927 }
1928 if !command.name.starts_with("refs/heads/") {
1929 return Ok(false);
1930 }
1931 let deny = config
1932 .get("receive", None, "denycurrentbranch")
1933 .unwrap_or("refuse");
1934 let denies = matches!(
1935 deny.to_ascii_lowercase().as_str(),
1936 "true" | "yes" | "on" | "1" | "refuse"
1937 );
1938 if !denies {
1939 return Ok(false);
1940 }
1941 if sley_worktree::worktree_root_for_git_dir(remote_git_dir)?.is_none() {
1942 return Ok(false);
1943 }
1944 let store = FileRefStore::new(remote_git_dir, format);
1945 Ok(matches!(
1946 store.read_ref("HEAD")?,
1947 Some(RefTarget::Symbolic(target)) if target == command.name
1948 ))
1949}
1950
1951fn receive_targets_current_branch(
1952 format: ObjectFormat,
1953 command: &ReceivePackCommand,
1954 remote_git_dir: &Path,
1955) -> Result<bool> {
1956 if !command.name.starts_with("refs/heads/") {
1957 return Ok(false);
1958 }
1959 if sley_worktree::worktree_root_for_git_dir(remote_git_dir)?.is_none() {
1960 return Ok(false);
1961 }
1962 let store = FileRefStore::new(remote_git_dir, format);
1963 Ok(matches!(
1964 store.read_ref("HEAD")?,
1965 Some(RefTarget::Symbolic(target)) if target == command.name
1966 ))
1967}
1968
1969fn receive_denies_current_branch_delete(
1970 format: ObjectFormat,
1971 command: &ReceivePackCommand,
1972 config: &GitConfig,
1973 request: &PushReportRequest<'_>,
1974 remote_git_dir: &Path,
1975) -> Result<bool> {
1976 if !receive_targets_current_branch(format, command, remote_git_dir)? {
1977 return Ok(false);
1978 }
1979 let deny = request
1980 .receive_config_overrides
1981 .iter()
1982 .rev()
1983 .find(|(candidate, _)| candidate.eq_ignore_ascii_case("denydeletecurrent"))
1984 .map(|(_, value)| value.as_str())
1985 .or_else(|| config.get("receive", None, "denydeletecurrent"))
1986 .unwrap_or("refuse");
1987 Ok(!matches!(
1988 deny.to_ascii_lowercase().as_str(),
1989 "ignore" | "warn" | "false" | "no" | "off" | "0"
1990 ))
1991}
1992
1993pub(crate) fn is_fast_forward(
1996 common_git_dir: &Path,
1997 db: &FileObjectDatabase,
1998 format: ObjectFormat,
1999 old: &ObjectId,
2000 new: &ObjectId,
2001) -> Result<bool> {
2002 let ancestors = sley_rev::ancestor_depths(common_git_dir, format, db, new)?;
2003 Ok(ancestors.contains_key(old))
2004}
2005
2006#[cfg(feature = "http")]
2009fn advertised_receive_pack_features(
2010 advertisements: &[RefAdvertisement],
2011) -> Result<ReceivePackFeatures> {
2012 advertisements
2013 .first()
2014 .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
2015 .transpose()
2016 .map(Option::unwrap_or_default)
2017}
2018
2019#[cfg(feature = "http")]
2022fn verify_remote_object_format(features: &ReceivePackFeatures, format: ObjectFormat) -> Result<()> {
2023 if let Some(remote_format) = features.object_format {
2024 if remote_format != format {
2025 return Err(GitError::InvalidObjectId(format!(
2026 "remote repository uses {}, local repository uses {}",
2027 remote_format.name(),
2028 format.name()
2029 )));
2030 }
2031 } else if format != ObjectFormat::Sha1 {
2032 return Err(GitError::InvalidObjectId(format!(
2033 "remote repository did not advertise object-format for {} push",
2034 format.name()
2035 )));
2036 }
2037 Ok(())
2038}
2039
2040pub(crate) fn receive_pack_push_options(
2045 features: &ReceivePackFeatures,
2046 format: ObjectFormat,
2047 quiet: bool,
2048 atomic: bool,
2049 push_options: &[String],
2050) -> ReceivePackPushRequestOptions {
2051 ReceivePackPushRequestOptions {
2052 report_status_v2: features.report_status_v2,
2053 report_status: features.report_status && !features.report_status_v2,
2054 ofs_delta: features.ofs_delta,
2055 side_band_64k: features.side_band_64k,
2056 quiet: quiet && features.quiet,
2057 atomic: atomic && features.atomic,
2058 push_options: push_options.to_vec(),
2059 object_format: features
2060 .object_format
2061 .filter(|_| format != ObjectFormat::Sha1),
2062 ..ReceivePackPushRequestOptions::default()
2063 }
2064}
2065
2066pub fn push_local_uses_receive_pack_server(
2068 config: &GitConfig,
2069 remote_git_dir: &Path,
2070 commands: &[ReceivePackCommand],
2071) -> bool {
2072 let _ = remote_git_dir;
2073 if config.get_bool("receive", None, "fsckObjects").is_some()
2074 || config.get_bool("transfer", None, "fsckObjects").is_some()
2075 || !config
2076 .section_entries_with_subsection("receive", Some("fsck"))
2077 .is_empty()
2078 {
2079 return true;
2080 }
2081 let patterns = parse_proc_receive_refs(config);
2082 if patterns.is_empty() {
2083 return false;
2084 }
2085 commands
2086 .iter()
2087 .any(|command| proc_receive_ref_matches(&patterns, command))
2088}
2089
2090struct LocalPushViaReceivePackRequest<'a> {
2091 remote_git_dir: &'a Path,
2092 source_common_git_dir: &'a Path,
2093 format: ObjectFormat,
2094 commands: &'a [ReceivePackCommand],
2095 push_options: &'a [String],
2096 atomic: bool,
2097 quiet: bool,
2098 remote_advertisements: &'a [RefAdvertisement],
2099 remote_stderr: Option<&'a mut Vec<u8>>,
2100}
2101
2102fn local_push_via_receive_pack_server(
2103 request: LocalPushViaReceivePackRequest<'_>,
2104) -> Result<ReceivePackPushReport> {
2105 let features = crate::local::receive_pack_features(request.format);
2106 let local_db = FileObjectDatabase::from_git_dir(request.source_common_git_dir, request.format);
2107 let pack_request = PushPackRequest {
2108 local_db: &local_db,
2109 format: request.format,
2110 commands: request.commands,
2111 pack_objects: &[],
2112 remote_advertisements: request.remote_advertisements,
2113 features: &features,
2114 options: receive_pack_push_options(
2115 &features,
2116 request.format,
2117 request.quiet,
2118 request.atomic,
2119 request.push_options,
2120 ),
2121 thin: false,
2122 };
2123 let header = sley_protocol::build_receive_pack_push_request_header(
2124 &features,
2125 request.commands.to_vec(),
2126 pack_request.options.clone(),
2127 )?;
2128 let mut packfile = Vec::new();
2129 write_push_packfile(&pack_request, &mut packfile)?;
2130 let config = sley_config::read_repo_config(request.remote_git_dir, None).unwrap_or_default();
2131 let validation = sley_fsck::FsckPolicy::from_config(
2132 &config,
2133 sley_fsck::FsckConfigKind::Receive,
2134 request.format,
2135 request.remote_git_dir,
2136 false,
2137 )?;
2138 let mut pack_reader = Cursor::new(packfile);
2139 let mut discard_stderr = Vec::new();
2140 let capture_stderr = request.remote_stderr.is_some();
2141 let remote_stderr = request.remote_stderr.unwrap_or(&mut discard_stderr);
2142 let outcome = serve_receive_pack(ReceivePackServerRequest {
2143 git_dir: request.remote_git_dir,
2144 format: request.format,
2145 header: &ReceivePackPushRequestHeader {
2146 commands: header.commands,
2147 push_options: header.push_options,
2148 },
2149 pack_reader: &mut pack_reader,
2150 config: &config,
2151 validation: &validation,
2152 options: ReceivePackServerOptions {
2153 quiet: request.quiet,
2154 remote_stderr: if capture_stderr {
2155 Some(remote_stderr)
2156 } else {
2157 None
2158 },
2159 run_post_hooks: false,
2160 },
2161 })?;
2162 Ok(match outcome.report {
2163 ReceivePackServerReport::V1(status) => ReceivePackPushReport::V1(status),
2164 ReceivePackServerReport::V2(status) => ReceivePackPushReport::V2(status),
2165 })
2166}
2167
2168pub fn run_local_push_post_hooks(
2169 remote_git_dir: &Path,
2170 commands: &[ReceivePackCommand],
2171 push_options: &[String],
2172 remote_stderr: &mut Vec<u8>,
2173 capture_stderr: bool,
2174) -> Result<()> {
2175 let landed: Vec<ReceivePackCommand> = commands
2176 .iter()
2177 .filter(|command| command.old_id != command.new_id)
2178 .cloned()
2179 .collect();
2180 if landed.is_empty() {
2181 return Ok(());
2182 }
2183 crate::receive_hooks::run_post_receive(
2184 remote_git_dir,
2185 &landed
2186 .iter()
2187 .map(|command| ReceivePackCommandState::new(command.clone()))
2188 .collect::<Vec<_>>(),
2189 push_options,
2190 remote_stderr,
2191 capture_stderr,
2192 )?;
2193 crate::receive_hooks::run_post_update(
2194 remote_git_dir,
2195 &landed
2196 .iter()
2197 .map(|command| ReceivePackCommandState::new(command.clone()))
2198 .collect::<Vec<_>>(),
2199 remote_stderr,
2200 capture_stderr,
2201 )?;
2202 crate::receive_hooks::run_push_to_checkout(remote_git_dir, remote_stderr, capture_stderr)?;
2203 Ok(())
2204}
2205
2206pub(crate) fn plan_push_command_forces(
2211 format: ObjectFormat,
2212 local_refs: &[PushSourceRef],
2213 remote_refs: &[RefAdvertisement],
2214 refspecs: &[String],
2215 force: bool,
2216) -> Result<Vec<(ReceivePackCommand, bool)>> {
2217 let parsed_refspecs = refspecs
2218 .iter()
2219 .map(|refspec| {
2220 let normalized = normalize_push_refspec_for_sources(refspec, local_refs, remote_refs)?;
2221 parse_refspec(&normalized)
2222 })
2223 .collect::<Result<Vec<_>>>()?;
2224 let mut command_forces = Vec::new();
2225 for refspec in &parsed_refspecs {
2226 for command in plan_push_commands(
2227 format,
2228 local_refs,
2229 remote_refs,
2230 std::slice::from_ref(refspec),
2231 )? {
2232 command_forces.push((command, force || refspec.force));
2233 }
2234 }
2235 Ok(command_forces)
2236}
2237
2238struct PlannedPushCommand {
2241 command: ReceivePackCommand,
2242 force: bool,
2243 source: Option<String>,
2244}
2245
2246fn plan_push_command_sources(
2252 format: ObjectFormat,
2253 local_refs: &[PushSourceRef],
2254 remote_refs: &[RefAdvertisement],
2255 refspecs: &[String],
2256 force: bool,
2257) -> Result<Vec<PlannedPushCommand>> {
2258 let mut planned = Vec::new();
2259 for refspec in refspecs {
2260 let normalized = normalize_push_refspec_for_sources(refspec, local_refs, remote_refs)?;
2261 let parsed = parse_refspec(&normalized)?;
2262 let commands = plan_push_commands(
2263 format,
2264 local_refs,
2265 remote_refs,
2266 std::slice::from_ref(&parsed),
2267 )?;
2268 for command in commands {
2269 let source = push_command_source_name(&parsed, &command);
2270 planned.push(PlannedPushCommand {
2271 command,
2272 force: force || parsed.force,
2273 source,
2274 });
2275 }
2276 }
2277 Ok(planned)
2278}
2279
2280fn push_command_source_name(refspec: &RefSpec, command: &ReceivePackCommand) -> Option<String> {
2285 let src = refspec.src.as_deref()?;
2286 if !refspec.pattern {
2287 return Some(src.to_string());
2288 }
2289 let (src_prefix, src_suffix) = src.split_once('*')?;
2290 let dst = refspec.dst.as_deref()?;
2291 let (dst_prefix, dst_suffix) = dst.split_once('*')?;
2292 let stem = command
2293 .name
2294 .strip_prefix(dst_prefix)
2295 .and_then(|rest| rest.strip_suffix(dst_suffix))?;
2296 Some(format!("{src_prefix}{stem}{src_suffix}"))
2297}
2298
2299pub(crate) fn add_revision_push_sources(
2300 git_dir: &Path,
2301 format: ObjectFormat,
2302 refspecs: &[String],
2303 local_refs: &mut Vec<PushSourceRef>,
2304) {
2305 let db = FileObjectDatabase::from_git_dir(git_dir, format);
2306 add_revision_push_sources_with_reader(git_dir, format, &db, refspecs, local_refs);
2307}
2308
2309fn add_revision_push_sources_with_reader(
2310 git_dir: &Path,
2311 format: ObjectFormat,
2312 reader: &impl ObjectReader,
2313 refspecs: &[String],
2314 local_refs: &mut Vec<PushSourceRef>,
2315) {
2316 for refspec in refspecs {
2317 let refspec = refspec.strip_prefix('+').unwrap_or(refspec);
2318 let src = refspec.split_once(':').map_or(refspec, |(src, _)| src);
2319 if src.is_empty() || src == "HEAD" {
2320 continue;
2321 }
2322 if src.starts_with("refs/") && local_refs.iter().any(|reference| reference.name == src) {
2323 continue;
2324 }
2325 if local_refs.iter().any(|reference| {
2326 reference.name == src
2327 || reference.name == format!("refs/heads/{src}")
2328 || reference.name == format!("refs/tags/{src}")
2329 }) {
2330 continue;
2331 }
2332 if let Ok(oid) = sley_rev::resolve_revision_with_reader(git_dir, format, reader, src)
2333 && !local_refs.iter().any(|reference| reference.name == src)
2334 {
2335 local_refs.push(PushSourceRef {
2336 name: src.to_string(),
2337 oid,
2338 });
2339 }
2340 }
2341}
2342
2343fn normalize_push_refspec_for_sources(
2344 refspec: &str,
2345 local_refs: &[PushSourceRef],
2346 remote_refs: &[RefAdvertisement],
2347) -> Result<String> {
2348 let (force, refspec) = refspec
2349 .strip_prefix('+')
2350 .map_or((false, refspec), |refspec| (true, refspec));
2351 let normalized = if let Some((src, dst)) = refspec.split_once(':') {
2352 let (src, src_kind) = normalize_push_source_refname(src, local_refs);
2353 let dst = if src.is_empty() {
2354 normalize_push_delete_destination_refname(dst, remote_refs)?
2355 } else {
2356 normalize_push_destination_refname(dst, src_kind, remote_refs)?
2357 };
2358 if !src.is_empty() && !dst.contains('*') && push_destination_is_onelevel_under_refs(&dst) {
2359 return Err(GitError::Command(format!(
2360 "destination refspec {dst} is not a valid ref"
2361 )));
2362 }
2363 format!("{src}:{dst}")
2364 } else {
2365 let (name, _) = normalize_push_source_refname(refspec, local_refs);
2366 let dst = match count_refspec_match_dst(&name, remote_refs) {
2373 DstMatch::Unique(matched) => matched.to_string(),
2374 DstMatch::None => name.clone(),
2375 DstMatch::Ambiguous => {
2376 return Err(GitError::Command(format!(
2377 "dst refspec {name} matches more than one"
2378 )));
2379 }
2380 };
2381 format!("{name}:{dst}")
2382 };
2383 Ok(if force {
2384 format!("+{normalized}")
2385 } else {
2386 normalized
2387 })
2388}
2389
2390fn refname_match_rank(abbrev: &str, full_name: &str) -> Option<usize> {
2394 const RULES: [&str; 6] = [
2395 "{}",
2396 "refs/{}",
2397 "refs/tags/{}",
2398 "refs/heads/{}",
2399 "refs/remotes/{}",
2400 "refs/remotes/{}/HEAD",
2401 ];
2402 for (idx, rule) in RULES.iter().enumerate() {
2403 let (prefix, suffix) = rule.split_once("{}").unwrap_or((rule, ""));
2404 if full_name == format!("{prefix}{abbrev}{suffix}") {
2405 return Some(RULES.len() - idx);
2406 }
2407 }
2408 None
2409}
2410
2411enum DstMatch<'a> {
2413 Unique(&'a str),
2415 None,
2417 Ambiguous,
2419}
2420
2421fn count_refspec_match_dst<'a>(pattern: &str, remote_refs: &'a [RefAdvertisement]) -> DstMatch<'a> {
2428 let patlen = pattern.len();
2429 let mut strong: Option<&str> = None;
2430 let mut strong_count = 0usize;
2431 let mut weak: Option<&str> = None;
2432 let mut weak_count = 0usize;
2433 for advert in remote_refs {
2434 let name = advert.name.as_str();
2435 if refname_match_rank(pattern, name).is_none() {
2436 continue;
2437 }
2438 let namelen = name.len();
2439 let is_weak = namelen != patlen
2440 && patlen + 5 != namelen
2441 && !name.starts_with("refs/heads/")
2442 && !name.starts_with("refs/tags/");
2443 if is_weak {
2444 weak = Some(name);
2445 weak_count += 1;
2446 } else {
2447 strong = Some(name);
2448 strong_count += 1;
2449 }
2450 }
2451 match (strong_count, weak_count, strong, weak) {
2452 (1, _, Some(matched), _) => DstMatch::Unique(matched),
2453 (0, 1, _, Some(matched)) => DstMatch::Unique(matched),
2454 (0, 0, _, _) => DstMatch::None,
2455 _ => DstMatch::Ambiguous,
2456 }
2457}
2458
2459#[derive(Clone, Copy)]
2460enum PushSourceKind {
2461 Branch,
2462 Tag,
2463 Other,
2467 Unqualifiable,
2471}
2472
2473fn normalize_push_source_refname(
2474 name: &str,
2475 local_refs: &[PushSourceRef],
2476) -> (String, PushSourceKind) {
2477 if name.is_empty() || name == "HEAD" || name == "@" || name.starts_with("refs/") {
2480 return (name.to_string(), PushSourceKind::Other);
2481 }
2482 let branch = format!("refs/heads/{name}");
2483 let tag = format!("refs/tags/{name}");
2484 let has_branch = local_refs.iter().any(|reference| reference.name == branch);
2485 let has_tag = local_refs.iter().any(|reference| reference.name == tag);
2486 if has_tag && !has_branch {
2487 (tag, PushSourceKind::Tag)
2488 } else if has_branch {
2489 (branch, PushSourceKind::Branch)
2490 } else if local_refs.iter().any(|reference| reference.name == name) {
2491 (name.to_string(), PushSourceKind::Unqualifiable)
2495 } else {
2496 (branch, PushSourceKind::Branch)
2497 }
2498}
2499
2500fn normalize_push_delete_destination_refname(
2501 name: &str,
2502 remote_refs: &[RefAdvertisement],
2503) -> Result<String> {
2504 if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
2505 return Ok(name.to_string());
2506 }
2507 match count_refspec_match_dst(name, remote_refs) {
2508 DstMatch::Unique(matched) => Ok(matched.to_string()),
2509 DstMatch::Ambiguous => Err(GitError::Command(format!(
2510 "dst refspec {name} matches more than one"
2511 ))),
2512 DstMatch::None => Err(GitError::reference_not_found(format!("remote ref {name}"))),
2513 }
2514}
2515
2516fn normalize_push_destination_refname(
2517 name: &str,
2518 src_kind: PushSourceKind,
2519 remote_refs: &[RefAdvertisement],
2520) -> Result<String> {
2521 if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
2522 return Ok(name.to_string());
2523 }
2524 match count_refspec_match_dst(name, remote_refs) {
2530 DstMatch::Unique(matched) => Ok(matched.to_string()),
2531 DstMatch::Ambiguous => Err(GitError::Command(format!(
2532 "dst refspec {name} matches more than one"
2533 ))),
2534 DstMatch::None => match src_kind {
2535 PushSourceKind::Tag => Ok(format!("refs/tags/{name}")),
2536 PushSourceKind::Branch | PushSourceKind::Other => Ok(format!("refs/heads/{name}")),
2537 PushSourceKind::Unqualifiable => Err(GitError::Command(format!(
2541 "the destination you provided is not a full refname (i.e., starting with \"refs/\"); unable to guess the destination for {name}"
2542 ))),
2543 },
2544 }
2545}
2546
2547fn push_destination_is_onelevel_under_refs(name: &str) -> bool {
2548 name.strip_prefix("refs/")
2549 .is_some_and(|rest| !rest.contains('/'))
2550}
2551
2552fn commands_from_forces(command_forces: &[(ReceivePackCommand, bool)]) -> Vec<ReceivePackCommand> {
2554 command_forces
2555 .iter()
2556 .map(|(command, _)| command.clone())
2557 .collect()
2558}
2559
2560fn receive_pack_commands_from_action_plan(
2561 format: ObjectFormat,
2562 plan: &PushActionPlan,
2563) -> Result<Vec<ReceivePackCommand>> {
2564 let zero = ObjectId::null(format);
2565 for oid in &plan.pack_objects {
2566 if oid.format() != format {
2567 return Err(GitError::InvalidObjectId(format!(
2568 "push pack object {oid} has {} object id for {} repository",
2569 oid.format().name(),
2570 format.name()
2571 )));
2572 }
2573 }
2574 plan.commands
2575 .iter()
2576 .map(|command| {
2577 let old_id = command.expected_old.unwrap_or(zero);
2578 let new_id = command.src.unwrap_or(zero);
2579 if old_id.format() != format {
2580 return Err(GitError::InvalidObjectId(format!(
2581 "push command {} expected old has {} object id for {} repository",
2582 command.dst,
2583 old_id.format().name(),
2584 format.name()
2585 )));
2586 }
2587 if new_id.format() != format {
2588 return Err(GitError::InvalidObjectId(format!(
2589 "push command {} new id has {} object id for {} repository",
2590 command.dst,
2591 new_id.format().name(),
2592 format.name()
2593 )));
2594 }
2595 Ok(ReceivePackCommand {
2596 old_id,
2597 new_id,
2598 name: command.dst.clone(),
2599 })
2600 })
2601 .collect()
2602}
2603
2604pub fn push_url_for_display(url: &str) -> String {
2607 redact_url_for_display(url)
2608}
2609
2610pub fn read_receive_pack_push_report(
2612 format: ObjectFormat,
2613 reader: &mut impl Read,
2614 use_report_v2: bool,
2615 use_sideband: bool,
2616) -> Result<ReceivePackPushReport> {
2617 read_receive_pack_push_report_with_progress(format, reader, use_report_v2, use_sideband, None)
2618}
2619
2620pub fn read_receive_pack_push_report_with_progress(
2621 format: ObjectFormat,
2622 reader: &mut impl Read,
2623 use_report_v2: bool,
2624 use_sideband: bool,
2625 mut remote_progress: Option<&mut Vec<u8>>,
2626) -> Result<ReceivePackPushReport> {
2627 if use_sideband {
2628 let demuxed = read_and_demux_sideband_stream(reader)?;
2629 for line in demuxed.progress {
2630 if let Some(buf) = remote_progress.as_mut() {
2631 buf.extend_from_slice(&line);
2632 if !line.ends_with(b"\n") {
2633 buf.push(b'\n');
2634 }
2635 } else {
2636 eprint!("{}", String::from_utf8_lossy(&line));
2637 }
2638 }
2639 let mut payload = demuxed.data.as_slice();
2640 if use_report_v2 {
2641 Ok(ReceivePackPushReport::V2(
2642 read_receive_pack_report_status_v2(format, &mut payload)?,
2643 ))
2644 } else {
2645 Ok(ReceivePackPushReport::V1(read_receive_pack_report_status(
2646 &mut payload,
2647 )?))
2648 }
2649 } else if use_report_v2 {
2650 Ok(ReceivePackPushReport::V2(
2651 read_receive_pack_report_status_v2(format, reader)?,
2652 ))
2653 } else {
2654 Ok(ReceivePackPushReport::V1(read_receive_pack_report_status(
2655 reader,
2656 )?))
2657 }
2658}
2659
2660pub fn apply_receive_pack_report_to_push_refs(
2662 refs: &mut [PushReportRef],
2663 report: &ReceivePackPushReport,
2664) {
2665 if let Some(message) = report.unpack_failed() {
2666 for reference in refs.iter_mut() {
2667 if matches!(reference.status, PushRefStatus::Ok) {
2668 reference.status = PushRefStatus::RemoteReject(if message == "unpacker error" {
2669 message.to_string()
2670 } else {
2671 format!("unpacker error: {message}")
2672 });
2673 }
2674 }
2675 return;
2676 }
2677 match report {
2678 ReceivePackPushReport::V1(status) => {
2679 for command_status in &status.commands {
2680 if let ReceivePackCommandStatus::Ng { name, message } = command_status {
2681 for reference in refs.iter_mut() {
2682 if reference.dst == *name && matches!(reference.status, PushRefStatus::Ok) {
2683 reference.status = PushRefStatus::RemoteReject(message.clone());
2684 }
2685 }
2686 }
2687 }
2688 }
2689 ReceivePackPushReport::V2(status) => {
2690 let mut hint: Option<usize> = None;
2691 for command in &status.commands {
2692 match command {
2693 ReceivePackCommandStatusV2::Ng { name, message } => {
2694 if let Some(idx) = find_push_report_ref_index(refs, name, hint) {
2695 hint = Some(idx);
2696 if matches!(refs[idx].status, PushRefStatus::Ok) {
2697 refs[idx].status = PushRefStatus::RemoteReject(message.clone());
2698 }
2699 }
2700 }
2701 ReceivePackCommandStatusV2::Ok { name, options } => {
2702 let Some(idx) = find_push_report_ref_index(refs, name, hint) else {
2703 continue;
2704 };
2705 hint = Some(idx);
2706 if proc_receive_options_are_nontrivial(options) {
2707 refs[idx]
2708 .reports
2709 .push(proc_receive_report_from_options(options));
2710 }
2711 }
2712 }
2713 }
2714 }
2715 }
2716}
2717
2718fn find_push_report_ref_index(
2719 refs: &[PushReportRef],
2720 name: &str,
2721 hint: Option<usize>,
2722) -> Option<usize> {
2723 if let Some(hint) = hint
2724 && refs
2725 .get(hint)
2726 .is_some_and(|reference| reference.dst == name)
2727 {
2728 return Some(hint);
2729 }
2730 refs.iter().position(|reference| reference.dst == name)
2731}
2732
2733fn proc_receive_report_from_options(
2734 options: &ReceivePackCommandStatusV2Options,
2735) -> ProcReceiveReport {
2736 ProcReceiveReport {
2737 refname: options.refname.clone(),
2738 old_oid: options.old_oid.clone(),
2739 new_oid: options.new_oid.clone(),
2740 forced_update: options.forced_update,
2741 }
2742}
2743
2744fn proc_receive_options_are_nontrivial(options: &ReceivePackCommandStatusV2Options) -> bool {
2745 options.refname.is_some()
2746 || options.old_oid.is_some()
2747 || options.new_oid.is_some()
2748 || options.forced_update
2749}
2750
2751pub fn validate_receive_pack_unpack(report: &ReceivePackPushReport) -> Result<()> {
2753 if let Some(message) = report.unpack_failed() {
2754 return Err(GitError::Command(format!(
2755 "failed to push some refs: unpack failed: {message}"
2756 )));
2757 }
2758 Ok(())
2759}
2760
2761pub fn validate_receive_pack_report(report: &ReceivePackReportStatus) -> Result<()> {
2764 if let ReceivePackUnpackStatus::Error(message) = &report.unpack {
2765 return Err(GitError::Command(format!(
2766 "failed to push some refs: unpack failed: {message}"
2767 )));
2768 }
2769 for status in &report.commands {
2770 if let ReceivePackCommandStatus::Ng { name, message } = status {
2771 return Err(GitError::Command(format!(
2772 "failed to push {name}: {message}"
2773 )));
2774 }
2775 }
2776 Ok(())
2777}
2778
2779pub fn local_push_source_refs(
2783 store: &FileRefStore,
2784 format: ObjectFormat,
2785) -> Result<Vec<PushSourceRef>> {
2786 let mut refs = Vec::new();
2787 for reference in store.list_refs()? {
2788 let Some((oid, _)) = resolve_for_each_ref_target(store, &reference)? else {
2789 continue;
2790 };
2791 if oid.format() != format {
2792 return Err(GitError::InvalidObjectId(format!(
2793 "local ref {} has {} object id for {} repository",
2794 reference.name,
2795 oid.format().name(),
2796 format.name()
2797 )));
2798 }
2799 refs.push(PushSourceRef {
2800 name: reference.name.clone(),
2801 oid,
2802 });
2803 if let Some(short) = reference.name.strip_prefix("refs/heads/") {
2804 refs.push(PushSourceRef {
2805 name: short.to_string(),
2806 oid,
2807 });
2808 }
2809 if let Some(short) = reference.name.strip_prefix("refs/tags/") {
2810 refs.push(PushSourceRef {
2811 name: short.to_string(),
2812 oid,
2813 });
2814 }
2815 }
2816 if let Some(target) = store.read_ref("HEAD")? {
2817 let head = Ref {
2818 name: "HEAD".to_string(),
2819 target,
2820 };
2821 if let Some((oid, _)) = resolve_for_each_ref_target(store, &head)?
2822 && oid.format() == format
2823 {
2824 refs.push(PushSourceRef {
2825 name: "HEAD".to_string(),
2826 oid,
2827 });
2828 }
2829 }
2830 Ok(refs)
2831}
2832
2833pub fn normalize_push_refspec(refspec: &str) -> String {
2837 let (force, refspec) = refspec
2838 .strip_prefix('+')
2839 .map_or((false, refspec), |refspec| (true, refspec));
2840 let normalized = if let Some((src, dst)) = refspec.split_once(':') {
2841 let src = normalize_push_refname(src);
2842 let dst = normalize_push_refname(dst);
2843 format!("{src}:{dst}")
2844 } else {
2845 let name = normalize_push_refname(refspec);
2846 format!("{name}:{name}")
2847 };
2848 if force {
2849 format!("+{normalized}")
2850 } else {
2851 normalized
2852 }
2853}
2854
2855pub fn normalize_push_refname(name: &str) -> String {
2858 if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
2859 name.to_string()
2860 } else {
2861 format!("refs/heads/{name}")
2862 }
2863}
2864
2865pub fn reject_non_fast_forward_pushes(
2869 common_git_dir: &Path,
2870 local_db: &FileObjectDatabase,
2871 format: ObjectFormat,
2872 command_forces: &[(ReceivePackCommand, bool)],
2873) -> Result<()> {
2874 for (command, force) in command_forces {
2875 if push_command_is_non_fast_forward(common_git_dir, local_db, format, command, *force)? {
2876 let short = command.name.trim_start_matches("refs/heads/");
2877 return Err(GitError::Command(format!(
2878 "failed to push some refs: non-fast-forward update to {short}"
2879 )));
2880 }
2881 }
2882 Ok(())
2883}
2884
2885fn push_command_is_non_fast_forward(
2886 common_git_dir: &Path,
2887 local_db: &FileObjectDatabase,
2888 format: ObjectFormat,
2889 command: &ReceivePackCommand,
2890 force: bool,
2891) -> Result<bool> {
2892 if force
2893 || !command.name.starts_with("refs/heads/")
2894 || command.old_id.is_null()
2895 || command.new_id.is_null()
2896 {
2897 return Ok(false);
2898 }
2899 let ancestors = sley_rev::ancestor_depths(common_git_dir, format, local_db, &command.new_id)?;
2900 Ok(!ancestors.contains_key(&command.old_id))
2901}
2902
2903fn resolve_for_each_ref_target(
2906 store: &FileRefStore,
2907 reference: &Ref,
2908) -> Result<Option<(ObjectId, Option<String>)>> {
2909 let mut target = reference.target.clone();
2910 let mut symref = None;
2911 for _ in 0..5 {
2912 match target {
2913 RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
2914 RefTarget::Symbolic(name) => {
2915 symref.get_or_insert_with(|| name.clone());
2916 let Some(next) = store.read_ref(&name)? else {
2917 return Ok(None);
2918 };
2919 target = next;
2920 }
2921 }
2922 }
2923 Ok(None)
2924}
2925
2926#[cfg(test)]
2927mod tests {
2928 use super::*;
2929 use std::fs;
2930 use std::sync::atomic::{AtomicU64, Ordering};
2931
2932 use sley_formats::RepositoryLayout;
2933 use sley_object::{BString, Commit, EncodedObject, ObjectType, Tree, TreeEntry};
2934 use sley_odb::{FileObjectDatabase, ObjectReplacements, ObjectWriter};
2935 use sley_protocol::{ReceivePackCommandStatus, ReceivePackUnpackStatus};
2936 use sley_refs::{RefTarget, RefUpdate};
2937
2938 use crate::{NoCredentials, SilentProgress};
2939
2940 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
2941
2942 fn temp_repo(name: &str) -> PathBuf {
2943 let dir = std::env::temp_dir().join(format!(
2944 "sley-remote-push-{name}-{}-{}",
2945 std::process::id(),
2946 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
2947 ));
2948 let _ = fs::remove_dir_all(&dir);
2949 RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
2950 .expect("test repository should initialize");
2951 dir.join(".git")
2952 }
2953
2954 fn write_commit(git_dir: &Path, parents: Vec<ObjectId>, message: &str) -> ObjectId {
2955 let format = ObjectFormat::Sha1;
2956 let db = FileObjectDatabase::from_git_dir(git_dir, format);
2957 let tree = db
2958 .write_object(EncodedObject::new(
2959 ObjectType::Tree,
2960 Tree { entries: vec![] }.write(),
2961 ))
2962 .expect("tree should write");
2963 let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
2964 db.write_object(EncodedObject::new(
2965 ObjectType::Commit,
2966 Commit {
2967 tree,
2968 parents,
2969 author: identity.clone(),
2970 committer: identity,
2971 encoding: None,
2972 message: format!("{message}\n").into_bytes(),
2973 }
2974 .write(),
2975 ))
2976 .expect("commit should write")
2977 }
2978
2979 fn write_commit_for_tree(
2980 git_dir: &Path,
2981 tree: ObjectId,
2982 parents: Vec<ObjectId>,
2983 message: &str,
2984 ) -> ObjectId {
2985 let db = FileObjectDatabase::from_git_dir(git_dir, ObjectFormat::Sha1);
2986 let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
2987 db.write_object(EncodedObject::new(
2988 ObjectType::Commit,
2989 Commit {
2990 tree,
2991 parents,
2992 author: identity.clone(),
2993 committer: identity,
2994 encoding: None,
2995 message: format!("{message}\n").into_bytes(),
2996 }
2997 .write(),
2998 ))
2999 .expect("commit should write")
3000 }
3001
3002 fn set_ref(git_dir: &Path, name: &str, target: RefTarget) {
3003 let store = FileRefStore::new(git_dir, ObjectFormat::Sha1);
3004 let mut tx = store.transaction();
3005 tx.update(RefUpdate {
3006 name: name.to_string(),
3007 expected: None,
3008 new: target,
3009 reflog: None,
3010 });
3011 tx.commit().expect("ref should update");
3012 }
3013
3014 fn default_options() -> PushOptions {
3015 PushOptions {
3016 quiet: true,
3017 force: false,
3018 thin: PushThinMode::Auto,
3019 atomic: false,
3020 push_options: Vec::new(),
3021 }
3022 }
3023
3024 #[test]
3025 fn revision_push_source_uses_injected_replacement_parent() {
3026 let git_dir = temp_repo("replacement-source");
3027 let original_parent = write_commit(&git_dir, Vec::new(), "original parent");
3028 let replacement_parent = write_commit(&git_dir, Vec::new(), "replacement parent");
3029 let original = write_commit(&git_dir, vec![original_parent], "original");
3030 let replacement = write_commit(&git_dir, vec![replacement_parent], "replacement");
3031 let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1)
3032 .with_replacements(ObjectReplacements::new([(original, replacement)]));
3033 let refspecs = [format!("{original}^:refs/heads/topic")];
3034 let mut sources = Vec::new();
3035
3036 add_revision_push_sources_with_reader(
3037 &git_dir,
3038 ObjectFormat::Sha1,
3039 &db,
3040 &refspecs,
3041 &mut sources,
3042 );
3043
3044 assert_eq!(sources.len(), 1);
3045 assert_eq!(sources[0].name, format!("{original}^"));
3046 assert_eq!(sources[0].oid, replacement_parent);
3047 }
3048
3049 #[derive(Default)]
3053 struct RecordingClient {
3054 last: std::sync::Mutex<Option<(&'static str, Vec<u8>)>>,
3055 }
3056
3057 impl RecordingClient {
3058 fn take(&self) -> (&'static str, Vec<u8>) {
3059 self.last
3060 .lock()
3061 .expect("lock")
3062 .take()
3063 .expect("a send was recorded")
3064 }
3065
3066 fn ok_response() -> Result<HttpResponse> {
3067 Ok(HttpResponse {
3068 status: 200,
3069 content_type: None,
3070 body: Box::new(std::io::empty()),
3071 })
3072 }
3073 }
3074
3075 impl HttpClient for RecordingClient {
3076 fn get(&self, _url: &str, _headers: &[(&str, &str)]) -> Result<HttpResponse> {
3077 Self::ok_response()
3078 }
3079
3080 fn post(
3081 &self,
3082 _url: &str,
3083 _content_type: &str,
3084 _headers: &[(&str, &str)],
3085 body: &[u8],
3086 ) -> Result<HttpResponse> {
3087 *self.last.lock().expect("lock") = Some(("post", body.to_vec()));
3088 Self::ok_response()
3089 }
3090
3091 fn post_reader(
3092 &self,
3093 _url: &str,
3094 _content_type: &str,
3095 _headers: &[(&str, &str)],
3096 body: &mut dyn Read,
3097 ) -> Result<HttpResponse> {
3098 let mut buffered = Vec::new();
3099 body.read_to_end(&mut buffered)
3100 .map_err(|err| GitError::Io(err.to_string()))?;
3101 *self.last.lock().expect("lock") = Some(("post_reader", buffered));
3102 Self::ok_response()
3103 }
3104 }
3105
3106 fn receive_pack_request<'a>(
3107 db: &'a FileObjectDatabase,
3108 commands: &'a [ReceivePackCommand],
3109 advertisements: &'a [RefAdvertisement],
3110 features: &'a ReceivePackFeatures,
3111 ) -> PushPackRequest<'a> {
3112 PushPackRequest {
3113 local_db: db,
3114 format: ObjectFormat::Sha1,
3115 commands,
3116 pack_objects: &[],
3117 remote_advertisements: advertisements,
3118 features,
3119 options: ReceivePackPushRequestOptions {
3120 report_status: true,
3121 ofs_delta: true,
3122 ..ReceivePackPushRequestOptions::default()
3123 },
3124 thin: false,
3125 }
3126 }
3127
3128 #[test]
3129 fn send_receive_pack_body_gates_on_post_buffer_and_preserves_bytes() {
3130 let git_dir = temp_repo("send-receive-pack-gate");
3131 let commit = write_commit(&git_dir, vec![], "streamed http push");
3132 let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
3133 let commands = [ReceivePackCommand {
3134 old_id: ObjectId::null(ObjectFormat::Sha1),
3135 new_id: commit,
3136 name: "refs/heads/main".into(),
3137 }];
3138 let features = ReceivePackFeatures {
3139 report_status: true,
3140 ofs_delta: true,
3141 ..ReceivePackFeatures::default()
3142 };
3143 let req = receive_pack_request(&db, &commands, &[], &features);
3144
3145 let mut canonical = Vec::new();
3147 write_receive_pack_body(&req, &mut canonical).expect("canonical body");
3148 assert!(canonical.len() > 1, "body should be non-trivial");
3149
3150 let buffered_client = RecordingClient::default();
3152 send_receive_pack_body(
3153 &buffered_client,
3154 "http://h/git-receive-pack",
3155 "ct",
3156 &[],
3157 &req,
3158 usize::MAX,
3159 )
3160 .expect("buffered send");
3161 let (method, body) = buffered_client.take();
3162 assert_eq!(method, "post");
3163 assert_eq!(body, canonical);
3164
3165 let streamed_client = RecordingClient::default();
3169 send_receive_pack_body(
3170 &streamed_client,
3171 "http://h/git-receive-pack",
3172 "ct",
3173 &[],
3174 &req,
3175 8,
3176 )
3177 .expect("streamed send");
3178 let (method, body) = streamed_client.take();
3179 assert_eq!(method, "post_reader");
3180 assert_eq!(body, canonical);
3181
3182 let _ = fs::remove_dir_all(git_dir.parent().unwrap_or(&git_dir));
3183 }
3184
3185 #[test]
3186 fn parse_post_buffer_reads_git_size_values() {
3187 assert_eq!(parse_post_buffer("1048576"), Some(1 << 20));
3188 assert_eq!(parse_post_buffer("512k"), Some(512 * 1024));
3189 assert_eq!(parse_post_buffer("1M"), Some(1024 * 1024));
3190 assert_eq!(parse_post_buffer("2g"), Some(2 * 1024 * 1024 * 1024));
3191 assert_eq!(parse_post_buffer(" 64k "), Some(64 * 1024));
3192 assert_eq!(parse_post_buffer("garbage"), None);
3193 assert_eq!(parse_post_buffer(""), None);
3194 }
3195
3196 #[test]
3197 fn push_action_plan_infers_pack_roots_from_non_delete_commands() {
3198 let repo = temp_repo("action-plan-infer-roots");
3199 let first = write_commit(&repo, Vec::new(), "first");
3200 let second = write_commit(&repo, vec![first], "second");
3201
3202 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
3203 vec![
3204 PushCommand {
3205 src: Some(first),
3206 dst: "refs/heads/main".into(),
3207 expected_old: None,
3208 force: false,
3209 },
3210 PushCommand {
3211 src: Some(second),
3212 dst: "refs/heads/topic".into(),
3213 expected_old: Some(first),
3214 force: true,
3215 },
3216 ],
3217 default_options(),
3218 );
3219
3220 assert_eq!(plan.pack_objects, vec![first, second]);
3221 assert!(!plan.commands[0].force);
3222 assert!(plan.commands[1].force);
3223 }
3224
3225 #[test]
3226 fn push_action_plan_inferred_pack_roots_exclude_deletes() {
3227 let repo = temp_repo("action-plan-delete-roots");
3228 let old = write_commit(&repo, Vec::new(), "old");
3229 let new = write_commit(&repo, vec![old], "new");
3230
3231 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
3232 vec![
3233 PushCommand {
3234 src: None,
3235 dst: "refs/heads/remove".into(),
3236 expected_old: Some(old),
3237 force: false,
3238 },
3239 PushCommand {
3240 src: Some(new),
3241 dst: "refs/heads/keep".into(),
3242 expected_old: Some(old),
3243 force: false,
3244 },
3245 ],
3246 default_options(),
3247 );
3248
3249 assert_eq!(plan.pack_objects, vec![new]);
3250 }
3251
3252 #[test]
3253 fn push_action_plan_inferred_pack_roots_dedupe_first_seen_order() {
3254 let repo = temp_repo("action-plan-dedupe-roots");
3255 let first = write_commit(&repo, Vec::new(), "first");
3256 let second = write_commit(&repo, Vec::new(), "second");
3257
3258 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
3259 vec![
3260 PushCommand {
3261 src: Some(second),
3262 dst: "refs/heads/second".into(),
3263 expected_old: None,
3264 force: false,
3265 },
3266 PushCommand {
3267 src: Some(first),
3268 dst: "refs/heads/first".into(),
3269 expected_old: None,
3270 force: false,
3271 },
3272 PushCommand {
3273 src: Some(second),
3274 dst: "refs/tags/second".into(),
3275 expected_old: None,
3276 force: false,
3277 },
3278 PushCommand {
3279 src: Some(first),
3280 dst: "refs/tags/first".into(),
3281 expected_old: None,
3282 force: false,
3283 },
3284 ],
3285 default_options(),
3286 );
3287
3288 assert_eq!(plan.pack_objects, vec![second, first]);
3289 }
3290
3291 fn push_local_actions(
3292 local: &Path,
3293 remote: &Path,
3294 plan: &PushActionPlan,
3295 ) -> Result<PushOutcome> {
3296 let destination = PushDestination::Local {
3297 git_dir: remote.to_path_buf(),
3298 common_git_dir: remote.to_path_buf(),
3299 };
3300 let config = GitConfig::default();
3301 let mut credentials = NoCredentials;
3302 let mut progress = SilentProgress;
3303 push_actions(
3304 PushActionRequest {
3305 git_dir: local,
3306 common_git_dir: local,
3307 format: ObjectFormat::Sha1,
3308 config: &config,
3309 remote: "origin",
3310 destination: &destination,
3311 plan,
3312 },
3313 PushServices {
3314 credentials: &mut credentials,
3315 progress: &mut progress,
3316 },
3317 )
3318 }
3319
3320 #[test]
3321 fn local_push_returns_success_report_status_and_updates_ref() {
3322 let local = temp_repo("local-success");
3323 let remote = temp_repo("remote-success");
3324 let base = write_commit(&local, Vec::new(), "base");
3325 let tip = write_commit(&local, vec![base], "tip");
3326 set_ref(&local, "refs/heads/main", RefTarget::Direct(tip));
3327 set_ref(
3328 &local,
3329 "HEAD",
3330 RefTarget::Symbolic("refs/heads/main".into()),
3331 );
3332 let destination = PushDestination::Local {
3333 git_dir: remote.clone(),
3334 common_git_dir: remote.clone(),
3335 };
3336 let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
3337 let options = default_options();
3338 let request = PushRequest {
3339 git_dir: &local,
3340 common_git_dir: &local,
3341 format: ObjectFormat::Sha1,
3342 config: &GitConfig::default(),
3343 remote: "origin",
3344 destination: &destination,
3345 refspecs: &refspecs,
3346 options: &options,
3347 };
3348 let mut credentials = NoCredentials;
3349 let mut progress = SilentProgress;
3350
3351 let outcome = push(
3352 request,
3353 PushServices {
3354 credentials: &mut credentials,
3355 progress: &mut progress,
3356 },
3357 )
3358 .expect("push should succeed");
3359
3360 assert_eq!(outcome.commands.len(), 1);
3361 let report = outcome.report.expect("local receive-pack reports status");
3362 match report {
3363 ReceivePackPushReport::V1(report) => {
3364 assert!(matches!(report.unpack, ReceivePackUnpackStatus::Ok));
3365 assert!(matches!(
3366 report.commands.as_slice(),
3367 [ReceivePackCommandStatus::Ok { name }] if name == "refs/heads/main"
3368 ));
3369 }
3370 ReceivePackPushReport::V2(report) => {
3371 assert!(matches!(report.unpack, ReceivePackUnpackStatus::Ok));
3372 assert!(matches!(
3373 report.commands.as_slice(),
3374 [ReceivePackCommandStatusV2::Ok { name, .. }] if name == "refs/heads/main"
3375 ));
3376 }
3377 }
3378 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
3379 assert_eq!(
3380 remote_refs
3381 .read_ref("refs/heads/main")
3382 .expect("remote ref should read"),
3383 Some(RefTarget::Direct(tip))
3384 );
3385 }
3386
3387 #[test]
3388 fn local_push_accepts_promised_gap_in_remote_have_closure() {
3389 let local = temp_repo("local-promisor-receiver");
3390 let remote = temp_repo("remote-promisor-receiver");
3391 let local_db = FileObjectDatabase::from_git_dir(&local, ObjectFormat::Sha1);
3392 let remote_db = FileObjectDatabase::from_git_dir(&remote, ObjectFormat::Sha1);
3393 let promised_blob = local_db
3394 .write_object(EncodedObject::new(
3395 ObjectType::Blob,
3396 b"payload held by the receiver's promisor\n".to_vec(),
3397 ))
3398 .expect("blob should write");
3399 let tree = local_db
3400 .write_object(EncodedObject::new(
3401 ObjectType::Tree,
3402 Tree {
3403 entries: vec![TreeEntry {
3404 mode: 0o100644,
3405 name: BString::from("payload"),
3406 oid: promised_blob,
3407 }],
3408 }
3409 .write(),
3410 ))
3411 .expect("tree should write");
3412 let base = write_commit_for_tree(&local, tree, Vec::new(), "base");
3413 let tip = write_commit_for_tree(&local, tree, vec![base], "tip");
3414 set_ref(&local, "refs/heads/main", RefTarget::Direct(tip));
3415 set_ref(
3416 &local,
3417 "HEAD",
3418 RefTarget::Symbolic("refs/heads/main".into()),
3419 );
3420
3421 sley_odb::build_and_install_reachable_pack_filtered(
3422 &local_db,
3423 &remote_db,
3424 ObjectFormat::Sha1,
3425 vec![base],
3426 &std::collections::HashSet::new(),
3427 RawPackInstallOptions {
3428 promisor: true,
3429 ..Default::default()
3430 },
3431 Some(sley_odb::PackObjectFilter::BlobNone),
3432 None,
3433 )
3434 .expect("filtered pack should build")
3435 .expect("filtered pack should install");
3436 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
3437 set_ref(
3438 &remote,
3439 "HEAD",
3440 RefTarget::Symbolic("refs/heads/unborn".into()),
3441 );
3442 let mut config = fs::read(remote.join("config")).expect("remote config should read");
3443 config.extend_from_slice(b"\n[remote \"lop\"]\n\tpromisor = true\n");
3444 fs::write(remote.join("config"), config).expect("promisor config should write");
3445 assert!(
3446 !remote_db
3447 .contains(&promised_blob)
3448 .expect("remote object lookup")
3449 );
3450
3451 let refspecs = ["refs/heads/main:refs/heads/main".to_string()];
3452 let report = push_local_with_report(
3453 PushReportRequest {
3454 git_dir: &local,
3455 common_git_dir: &local,
3456 format: ObjectFormat::Sha1,
3457 remote_git_dir: &remote,
3458 remote_common_git_dir: &remote,
3459 refspecs: &refspecs,
3460 force: false,
3461 atomic: false,
3462 dry_run: false,
3463 force_with_lease: &[],
3464 force_with_lease_default: false,
3465 force_if_includes: false,
3466 receive_config_overrides: &[],
3467 push_options: &[],
3468 remote_stderr: None,
3469 quiet: true,
3470 },
3471 &GitConfig::default(),
3472 )
3473 .expect("push into incomplete promisor receiver should succeed");
3474
3475 assert_eq!(report.refs.len(), 1, "unexpected push report: {report:#?}");
3476 let reference = &report.refs[0];
3477 assert_eq!(reference.dst, "refs/heads/main");
3478 assert_eq!(reference.new_id, tip);
3479 assert_eq!(reference.status, PushRefStatus::Ok);
3480 assert_eq!(
3481 FileRefStore::new(&remote, ObjectFormat::Sha1)
3482 .read_ref("refs/heads/main")
3483 .expect("remote ref should read"),
3484 Some(RefTarget::Direct(tip))
3485 );
3486 remote_db.refresh_read_cache();
3487 assert!(
3488 !remote_db
3489 .contains(&promised_blob)
3490 .expect("receiver should retain promised gap")
3491 );
3492 }
3493
3494 #[test]
3495 fn local_push_actions_preserves_exact_old_new_update() {
3496 let local = temp_repo("actions-update-local");
3497 let remote = temp_repo("actions-update-remote");
3498 let base = write_commit(&local, Vec::new(), "base");
3499 let remote_base = write_commit(&remote, Vec::new(), "base");
3500 assert_eq!(remote_base, base);
3501 let tip = write_commit(&local, vec![base], "tip");
3502 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
3503 let plan = PushActionPlan::from_actions(
3504 vec![PushAction::Update {
3505 dst: "refs/heads/main".into(),
3506 old: base,
3507 new: tip,
3508 }],
3509 default_options(),
3510 );
3511
3512 let outcome = push_local_actions(&local, &remote, &plan).expect("push actions");
3513
3514 assert_eq!(outcome.commands.len(), 1);
3515 assert_eq!(outcome.commands[0].old_id, base);
3516 assert_eq!(outcome.commands[0].new_id, tip);
3517 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
3518 assert_eq!(
3519 remote_refs
3520 .read_ref("refs/heads/main")
3521 .expect("remote ref should read"),
3522 Some(RefTarget::Direct(tip))
3523 );
3524 }
3525
3526 #[test]
3527 fn local_push_actions_honors_per_command_force() {
3528 let local = temp_repo("actions-command-force-local");
3529 let remote = temp_repo("actions-command-force-remote");
3530 let base = write_commit(&local, Vec::new(), "base");
3531 let remote_base = write_commit(&remote, Vec::new(), "base");
3532 assert_eq!(remote_base, base);
3533 let unrelated = write_commit(&local, Vec::new(), "unrelated");
3534 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
3535
3536 let unforced = PushActionPlan::from_commands(
3537 vec![PushCommand {
3538 src: Some(unrelated),
3539 dst: "refs/heads/main".into(),
3540 expected_old: Some(base),
3541 force: false,
3542 }],
3543 default_options(),
3544 );
3545 let err = push_local_actions(&local, &remote, &unforced)
3546 .expect_err("non-fast-forward should reject without command force");
3547 assert!(err.to_string().contains("non-fast-forward"));
3548
3549 let forced = PushActionPlan::from_commands(
3550 vec![PushCommand {
3551 src: Some(unrelated),
3552 dst: "refs/heads/main".into(),
3553 expected_old: Some(base),
3554 force: true,
3555 }],
3556 default_options(),
3557 );
3558 let outcome = push_local_actions(&local, &remote, &forced).expect("command force pushes");
3559
3560 assert_eq!(outcome.commands.len(), 1);
3561 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
3562 assert_eq!(
3563 remote_refs
3564 .read_ref("refs/heads/main")
3565 .expect("remote ref should read"),
3566 Some(RefTarget::Direct(unrelated))
3567 );
3568 }
3569
3570 #[test]
3571 fn local_push_actions_command_force_is_precise_for_non_ff_validation() {
3572 let local = temp_repo("actions-command-force-precise-local");
3573 let remote = temp_repo("actions-command-force-precise-remote");
3574 let base = write_commit(&local, Vec::new(), "base");
3575 let remote_base = write_commit(&remote, Vec::new(), "base");
3576 assert_eq!(remote_base, base);
3577 let forced_unrelated = write_commit(&local, Vec::new(), "forced unrelated");
3578 let unforced_unrelated = write_commit(&local, Vec::new(), "unforced unrelated");
3579 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
3580 set_ref(&remote, "refs/heads/topic", RefTarget::Direct(base));
3581 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
3582 vec![
3583 PushCommand {
3584 src: Some(forced_unrelated),
3585 dst: "refs/heads/main".into(),
3586 expected_old: Some(base),
3587 force: true,
3588 },
3589 PushCommand {
3590 src: Some(unforced_unrelated),
3591 dst: "refs/heads/topic".into(),
3592 expected_old: Some(base),
3593 force: false,
3594 },
3595 ],
3596 default_options(),
3597 );
3598
3599 let err = push_local_actions(&local, &remote, &plan)
3600 .expect_err("only the forced command should bypass non-fast-forward validation");
3601
3602 assert!(err.to_string().contains("non-fast-forward update to topic"));
3603 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
3604 assert_eq!(
3605 remote_refs
3606 .read_ref("refs/heads/main")
3607 .expect("remote ref should read"),
3608 Some(RefTarget::Direct(base))
3609 );
3610 assert_eq!(
3611 remote_refs
3612 .read_ref("refs/heads/topic")
3613 .expect("remote ref should read"),
3614 Some(RefTarget::Direct(base))
3615 );
3616 }
3617
3618 #[test]
3619 fn local_push_actions_stale_update_old_rejects_without_mutating() {
3620 let local = temp_repo("actions-stale-local");
3621 let remote = temp_repo("actions-stale-remote");
3622 let base = write_commit(&local, Vec::new(), "base");
3623 let remote_base = write_commit(&remote, Vec::new(), "base");
3624 assert_eq!(remote_base, base);
3625 let tip = write_commit(&local, vec![base], "tip");
3626 let concurrent = write_commit(&remote, vec![base], "concurrent");
3627 set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
3628 let plan = PushActionPlan::from_actions(
3629 vec![PushAction::Update {
3630 dst: "refs/heads/main".into(),
3631 old: base,
3632 new: tip,
3633 }],
3634 default_options(),
3635 );
3636
3637 let err = push_local_actions(&local, &remote, &plan).expect_err("stale old rejects");
3638
3639 assert!(err.to_string().contains("expected ref refs/heads/main"));
3640 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
3641 assert_eq!(
3642 remote_refs
3643 .read_ref("refs/heads/main")
3644 .expect("remote ref should read"),
3645 Some(RefTarget::Direct(concurrent))
3646 );
3647 }
3648
3649 #[test]
3650 fn local_push_actions_stale_delete_old_rejects_without_mutating() {
3651 let local = temp_repo("actions-delete-local");
3652 let remote = temp_repo("actions-delete-remote");
3653 let base = write_commit(&local, Vec::new(), "base");
3654 let remote_base = write_commit(&remote, Vec::new(), "base");
3655 assert_eq!(remote_base, base);
3656 let concurrent = write_commit(&remote, vec![base], "concurrent");
3657 set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
3658 let plan = PushActionPlan::from_actions(
3659 vec![PushAction::Delete {
3660 dst: "refs/heads/main".into(),
3661 old: Some(base),
3662 }],
3663 default_options(),
3664 );
3665
3666 let err = push_local_actions(&local, &remote, &plan).expect_err("stale delete rejects");
3667
3668 assert!(err.to_string().contains("expected ref refs/heads/main"));
3669 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
3670 assert_eq!(
3671 remote_refs
3672 .read_ref("refs/heads/main")
3673 .expect("remote ref should read"),
3674 Some(RefTarget::Direct(concurrent))
3675 );
3676 }
3677
3678 #[test]
3679 fn local_push_actions_create_rejects_existing_ref() {
3680 let local = temp_repo("actions-create-local");
3681 let remote = temp_repo("actions-create-remote");
3682 let base = write_commit(&local, Vec::new(), "base");
3683 let remote_base = write_commit(&remote, Vec::new(), "base");
3684 assert_eq!(remote_base, base);
3685 let tip = write_commit(&local, vec![base], "tip");
3686 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
3687 let plan = PushActionPlan::from_actions(
3688 vec![PushAction::Create {
3689 dst: "refs/heads/main".into(),
3690 new: tip,
3691 }],
3692 default_options(),
3693 );
3694
3695 let err = push_local_actions(&local, &remote, &plan).expect_err("create must be absent");
3696
3697 assert!(
3698 err.to_string()
3699 .contains("expected ref refs/heads/main to not already exist")
3700 );
3701 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
3702 assert_eq!(
3703 remote_refs
3704 .read_ref("refs/heads/main")
3705 .expect("remote ref should read"),
3706 Some(RefTarget::Direct(base))
3707 );
3708 }
3709
3710 #[test]
3711 fn push_url_for_display_redacts_embedded_credentials() {
3712 assert_eq!(
3713 push_url_for_display("https://user:pass@host/repo.git"),
3714 "https://<redacted>@host/repo.git"
3715 );
3716 }
3717
3718 #[test]
3719 fn report_status_rejection_is_an_error() {
3720 let report = ReceivePackReportStatus {
3721 unpack: ReceivePackUnpackStatus::Ok,
3722 commands: vec![ReceivePackCommandStatus::Ng {
3723 name: "refs/heads/main".into(),
3724 message: "hook declined".into(),
3725 }],
3726 };
3727
3728 let err = validate_receive_pack_report(&report).expect_err("ng report should fail");
3729
3730 assert!(err.to_string().contains("hook declined"));
3731 }
3732
3733 #[test]
3734 fn failed_local_push_does_not_partially_mutate_remote_ref() {
3735 let local = temp_repo("local-rejected");
3736 let remote = temp_repo("remote-rejected");
3737 let base = write_commit(&local, Vec::new(), "base");
3738 let planned = write_commit(&local, vec![base], "planned");
3739 let concurrent = write_commit(&local, vec![base], "concurrent");
3740 set_ref(&local, "refs/heads/main", RefTarget::Direct(planned));
3741 set_ref(
3742 &local,
3743 "HEAD",
3744 RefTarget::Symbolic("refs/heads/main".into()),
3745 );
3746 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
3747 let destination = PushDestination::Local {
3748 git_dir: remote.clone(),
3749 common_git_dir: remote.clone(),
3750 };
3751 let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
3752 let options = default_options();
3753 let request = PushRequest {
3754 git_dir: &local,
3755 common_git_dir: &local,
3756 format: ObjectFormat::Sha1,
3757 config: &GitConfig::default(),
3758 remote: "origin",
3759 destination: &destination,
3760 refspecs: &refspecs,
3761 options: &options,
3762 };
3763 let mut credentials = NoCredentials;
3764 let mut progress = SilentProgress;
3765 let mut services = PushServices {
3766 credentials: &mut credentials,
3767 progress: &mut progress,
3768 };
3769 let plan = plan_push(request, &mut services).expect("push should plan");
3770
3771 set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
3772 let _err = execute_push_plan(request, &mut services, plan)
3773 .expect_err("stale old id should reject the ref update");
3774
3775 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
3776 assert_eq!(
3777 remote_refs
3778 .read_ref("refs/heads/main")
3779 .expect("remote ref should read"),
3780 Some(RefTarget::Direct(concurrent))
3781 );
3782 }
3783}