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